@growthub/cli 0.3.59 → 0.3.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/worker-kits/growthub-zernio-social-v1/.env.example +5 -0
- package/assets/worker-kits/growthub-zernio-social-v1/QUICKSTART.md +36 -4
- package/assets/worker-kits/growthub-zernio-social-v1/bundles/growthub-zernio-social-v1.json +30 -1
- package/assets/worker-kits/growthub-zernio-social-v1/docs/growthub-agentic-social-platform-ui-shell.md +134 -0
- package/assets/worker-kits/growthub-zernio-social-v1/docs/local-adapters.md +2 -2
- package/assets/worker-kits/growthub-zernio-social-v1/growthub-meta/README.md +5 -8
- package/assets/worker-kits/growthub-zernio-social-v1/growthub-meta/kit-standard.md +1 -1
- package/assets/worker-kits/growthub-zernio-social-v1/kit.json +33 -1
- package/assets/worker-kits/growthub-zernio-social-v1/skills.md +1 -1
- package/assets/worker-kits/growthub-zernio-social-v1/studio/.env.example +3 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/dist/assets/index-DTmBMuXr.js +78 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/dist/assets/index-gHr-nTMF.css +1 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/dist/index.html +14 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/index.html +13 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/package-lock.json +1677 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/package.json +20 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/serve.mjs +60 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/App.jsx +130 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/api.js +146 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/app.css +558 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/lib/rules.js +64 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/lib/templates.js +207 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/main.jsx +10 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Accounts.jsx +57 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Agent.jsx +167 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Analytics.jsx +164 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/ApiKeys.jsx +143 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Automations.jsx +122 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/CommentRules.jsx +592 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Compose.jsx +185 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Dashboard.jsx +87 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Inbox.jsx +144 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Queues.jsx +167 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Scheduled.jsx +85 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Sequences.jsx +160 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Templates.jsx +275 -0
- package/assets/worker-kits/growthub-zernio-social-v1/studio/vite.config.js +7 -0
- package/assets/worker-kits/growthub-zernio-social-v1/workers/zernio-social-operator/CLAUDE.md +3 -3
- package/dist/index.js +1183 -592
- package/package.json +1 -1
- package/assets/worker-kits/growthub-zernio-social-v1/docs/postiz-ui-shell-integration.md +0 -166
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { api, PROFILE_ID } from '../api.js';
|
|
3
|
+
import { useApp } from '../App.jsx';
|
|
4
|
+
|
|
5
|
+
const PLT_ICON = { twitter:'𝕏', linkedin:'in', instagram:'📸', facebook:'f', tiktok:'🎵', youtube:'▶', bluesky:'🦋', threads:'@', reddit:'r/', pinterest:'P', telegram:'✈', whatsapp:'W' };
|
|
6
|
+
const CHAR_LIMIT = { twitter: 280, bluesky: 300, threads: 500, pinterest: 500, linkedin: 3000, instagram: 2200, tiktok: 2200, facebook: 63206, youtube: 5000, telegram: 4096, whatsapp: 1024 };
|
|
7
|
+
|
|
8
|
+
export default function Compose({ onNavigate }) {
|
|
9
|
+
const { accounts, showToast } = useApp();
|
|
10
|
+
const [content, setContent] = useState('');
|
|
11
|
+
const [selected, setSelected] = useState([]);
|
|
12
|
+
const [mode, setMode] = useState('time'); // 'time' | 'queue'
|
|
13
|
+
const [schedTime, setSchedTime] = useState('');
|
|
14
|
+
const [queues, setQueues] = useState([]);
|
|
15
|
+
const [queueId, setQueueId] = useState('');
|
|
16
|
+
const [mediaFile, setMediaFile] = useState(null);
|
|
17
|
+
const [mediaId, setMediaId] = useState('');
|
|
18
|
+
const [submitting, setSubmitting] = useState(false);
|
|
19
|
+
const [banner, setBanner] = useState(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!PROFILE_ID) return;
|
|
23
|
+
api.getQueues(PROFILE_ID)
|
|
24
|
+
.then(d => setQueues(d.queues || []))
|
|
25
|
+
.catch(() => {});
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
const toggle = (a) => {
|
|
29
|
+
setSelected(s => s.find(x => x._id === a._id) ? s.filter(x => x._id !== a._id) : [...s, a]);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const charLimit = () => {
|
|
33
|
+
if (!selected.length) return null;
|
|
34
|
+
const mins = selected.map(a => CHAR_LIMIT[a.platform] || 9999);
|
|
35
|
+
return Math.min(...mins);
|
|
36
|
+
};
|
|
37
|
+
const limit = charLimit();
|
|
38
|
+
const over = limit && content.length > limit;
|
|
39
|
+
|
|
40
|
+
const uploadIfNeeded = async () => {
|
|
41
|
+
if (!mediaFile) return null;
|
|
42
|
+
const res = await api.uploadMedia(mediaFile);
|
|
43
|
+
return res.mediaId || res._id;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const submit = async () => {
|
|
47
|
+
if (!content.trim()) { showToast('Add a caption first', false); return; }
|
|
48
|
+
if (!selected.length) { showToast('Select at least one platform', false); return; }
|
|
49
|
+
if (mode === 'time' && !schedTime) { showToast('Pick a schedule time or switch to Queue mode', false); return; }
|
|
50
|
+
if (mode === 'queue' && !queueId) { showToast('Select a queue', false); return; }
|
|
51
|
+
|
|
52
|
+
setSubmitting(true);
|
|
53
|
+
setBanner(null);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
let uploadedMediaId = mediaId;
|
|
57
|
+
if (mediaFile && !mediaId) {
|
|
58
|
+
uploadedMediaId = await uploadIfNeeded();
|
|
59
|
+
setMediaId(uploadedMediaId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const platforms = selected.map(a => ({ platform: a.platform, accountId: a._id || a.accountId }));
|
|
63
|
+
const ikey = `compose-${Date.now()}`;
|
|
64
|
+
const body = { profileId: PROFILE_ID, content, platforms };
|
|
65
|
+
|
|
66
|
+
if (mode === 'time') body.scheduledFor = new Date(schedTime).toISOString();
|
|
67
|
+
else body.queueId = queueId;
|
|
68
|
+
|
|
69
|
+
if (uploadedMediaId) body.media = [{ mediaId: uploadedMediaId }];
|
|
70
|
+
|
|
71
|
+
const res = await api.createPost(body, ikey);
|
|
72
|
+
setBanner({ ok: true, msg: `✓ Post scheduled! ID: ${res.id || res._id || 'created'}` });
|
|
73
|
+
showToast('Post scheduled ✓');
|
|
74
|
+
setContent('');
|
|
75
|
+
setSelected([]);
|
|
76
|
+
setSchedTime('');
|
|
77
|
+
setMediaFile(null);
|
|
78
|
+
setMediaId('');
|
|
79
|
+
} catch (e) {
|
|
80
|
+
setBanner({ ok: false, msg: `✗ ${e.message}` });
|
|
81
|
+
showToast(e.message, false);
|
|
82
|
+
} finally {
|
|
83
|
+
setSubmitting(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div style={{ maxWidth: 700 }}>
|
|
89
|
+
{banner && (
|
|
90
|
+
<div className={`banner ${banner.ok ? 'banner-ok' : 'banner-err'}`}>{banner.msg}</div>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
<div className="card mb16">
|
|
94
|
+
<div className="field">
|
|
95
|
+
<label>Caption</label>
|
|
96
|
+
<textarea
|
|
97
|
+
className="textarea"
|
|
98
|
+
placeholder="Write your post…"
|
|
99
|
+
value={content}
|
|
100
|
+
onChange={e => setContent(e.target.value)}
|
|
101
|
+
/>
|
|
102
|
+
{limit && (
|
|
103
|
+
<div className={`char-count ${over ? 'char-over' : ''}`}>
|
|
104
|
+
{content.length} / {limit}{over ? ' — over limit for selected platforms' : ''}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div className="card mb16">
|
|
111
|
+
<div className="field" style={{ marginBottom: 0 }}>
|
|
112
|
+
<label>Platforms</label>
|
|
113
|
+
{!accounts.length ? (
|
|
114
|
+
<div style={{ color: 'var(--muted)', fontSize: 13 }}>No accounts connected.</div>
|
|
115
|
+
) : (
|
|
116
|
+
<div className="plat-row">
|
|
117
|
+
{accounts.map(a => (
|
|
118
|
+
<span
|
|
119
|
+
key={a._id}
|
|
120
|
+
className={`plat-toggle ${selected.find(x => x._id === a._id) ? 'selected' : ''}`}
|
|
121
|
+
onClick={() => toggle(a)}
|
|
122
|
+
>
|
|
123
|
+
{PLT_ICON[a.platform] || a.platform} {a.displayName || a.username}
|
|
124
|
+
</span>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div className="card mb16">
|
|
132
|
+
<div className="field">
|
|
133
|
+
<label>Scheduling Mode</label>
|
|
134
|
+
<div className="row mb8">
|
|
135
|
+
<button className={`btn btn-sm ${mode === 'time' ? 'btn-primary' : 'btn-ghost'}`} onClick={() => setMode('time')}>Specific Time</button>
|
|
136
|
+
<button className={`btn btn-sm ${mode === 'queue' ? 'btn-primary' : 'btn-ghost'}`} onClick={() => setMode('queue')}>Add to Queue</button>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{mode === 'time' ? (
|
|
141
|
+
<div className="field" style={{ marginBottom: 0 }}>
|
|
142
|
+
<label>Schedule At</label>
|
|
143
|
+
<input className="input" type="datetime-local" value={schedTime} onChange={e => setSchedTime(e.target.value)} />
|
|
144
|
+
</div>
|
|
145
|
+
) : (
|
|
146
|
+
<div className="field" style={{ marginBottom: 0 }}>
|
|
147
|
+
<label>Queue</label>
|
|
148
|
+
{queues.length ? (
|
|
149
|
+
<select className="select" value={queueId} onChange={e => setQueueId(e.target.value)}>
|
|
150
|
+
<option value="">— select queue —</option>
|
|
151
|
+
{queues.map(q => (
|
|
152
|
+
<option key={q._id} value={q._id}>{q.name}</option>
|
|
153
|
+
))}
|
|
154
|
+
</select>
|
|
155
|
+
) : (
|
|
156
|
+
<div style={{ color: 'var(--muted)', fontSize: 13 }}>No queues defined. <span style={{ color: 'var(--accentl)', cursor: 'pointer' }} onClick={() => onNavigate('queues')}>Create one →</span></div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div className="card mb16">
|
|
163
|
+
<div className="field" style={{ marginBottom: 0 }}>
|
|
164
|
+
<label>Media (optional)</label>
|
|
165
|
+
<input
|
|
166
|
+
className="input"
|
|
167
|
+
type="file"
|
|
168
|
+
accept="image/*,video/*"
|
|
169
|
+
style={{ cursor: 'pointer', padding: '7px 12px' }}
|
|
170
|
+
onChange={e => { setMediaFile(e.target.files[0] || null); setMediaId(''); }}
|
|
171
|
+
/>
|
|
172
|
+
{mediaFile && <div style={{ fontSize: 12, color: 'var(--dim)', marginTop: 5 }}>{mediaFile.name} — will upload on submit</div>}
|
|
173
|
+
{mediaId && <div style={{ fontSize: 12, color: 'var(--greenl)', marginTop: 5 }}>Uploaded: {mediaId}</div>}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div className="row-end">
|
|
178
|
+
<button className="btn btn-secondary" onClick={() => { setContent(''); setSelected([]); setSchedTime(''); setMediaFile(null); setBanner(null); }}>Clear</button>
|
|
179
|
+
<button className="btn btn-primary" onClick={submit} disabled={submitting || over}>
|
|
180
|
+
{submitting ? <><span className="spinner" style={{ marginRight: 7 }} />Scheduling…</> : 'Schedule Post'}
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { api, PROFILE_ID } from '../api.js';
|
|
3
|
+
import { useApp } from '../App.jsx';
|
|
4
|
+
|
|
5
|
+
const PLT_ICON = { twitter:'𝕏', linkedin:'in', instagram:'📸', facebook:'f', tiktok:'🎵', youtube:'▶', bluesky:'🦋', threads:'@', reddit:'r/', pinterest:'P', telegram:'✈', whatsapp:'W' };
|
|
6
|
+
const PLT_BG = { twitter:'#000', linkedin:'#0077b5', instagram:'#e1306c', facebook:'#1877f2', tiktok:'#010101', youtube:'#ff0000', bluesky:'#0085ff', threads:'#101010', reddit:'#ff4500', pinterest:'#e60023', telegram:'#0088cc', whatsapp:'#25d366' };
|
|
7
|
+
|
|
8
|
+
export default function Dashboard({ onNavigate }) {
|
|
9
|
+
const { accounts, profile, showToast } = useApp();
|
|
10
|
+
const [scheduled, setScheduled] = useState(null);
|
|
11
|
+
const [queues, setQueues] = useState(null);
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!PROFILE_ID) { setLoading(false); return; }
|
|
16
|
+
Promise.all([
|
|
17
|
+
api.getPosts(PROFILE_ID, 'scheduled'),
|
|
18
|
+
api.getQueues(PROFILE_ID),
|
|
19
|
+
])
|
|
20
|
+
.then(([posts, qs]) => {
|
|
21
|
+
setScheduled((posts.posts || []).length);
|
|
22
|
+
setQueues((qs.queues || []).length);
|
|
23
|
+
})
|
|
24
|
+
.catch(e => showToast(e.message, false))
|
|
25
|
+
.finally(() => setLoading(false));
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div>
|
|
30
|
+
<div className="stats-grid">
|
|
31
|
+
<div className="stat-card">
|
|
32
|
+
<div className="stat-label">Profile</div>
|
|
33
|
+
<div className="stat-value" style={{ fontSize: 18, paddingTop: 4 }}>{profile?.name || '—'}</div>
|
|
34
|
+
<div className="stat-sub">{PROFILE_ID || 'not set'}</div>
|
|
35
|
+
</div>
|
|
36
|
+
<div className="stat-card">
|
|
37
|
+
<div className="stat-label">Accounts</div>
|
|
38
|
+
<div className="stat-value">{accounts.length}</div>
|
|
39
|
+
<div className="stat-sub">connected platforms</div>
|
|
40
|
+
</div>
|
|
41
|
+
<div className="stat-card">
|
|
42
|
+
<div className="stat-label">Scheduled</div>
|
|
43
|
+
<div className="stat-value">{loading ? '…' : scheduled ?? '—'}</div>
|
|
44
|
+
<div className="stat-sub">pending posts</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="stat-card">
|
|
47
|
+
<div className="stat-label">Queues</div>
|
|
48
|
+
<div className="stat-value">{loading ? '…' : queues ?? '—'}</div>
|
|
49
|
+
<div className="stat-sub">recurring slots</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div className="row mb16" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
|
54
|
+
<div className="section-title" style={{ marginBottom: 0 }}>Connected Platforms</div>
|
|
55
|
+
<button className="btn btn-ghost btn-sm" onClick={() => onNavigate('accounts')}>View all →</button>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{!accounts.length && !loading && (
|
|
59
|
+
<div className="empty"><div className="empty-icon">🔗</div><div className="empty-msg">No accounts connected on this profile</div></div>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
{accounts.map(a => (
|
|
63
|
+
<div key={a._id || a.accountId} className="account-card">
|
|
64
|
+
<div className="platform-icon" style={{ background: PLT_BG[a.platform] || '#3f3f46', color: '#fff' }}>
|
|
65
|
+
{PLT_ICON[a.platform] || a.platform?.[0]?.toUpperCase()}
|
|
66
|
+
</div>
|
|
67
|
+
<div className="acc-info">
|
|
68
|
+
<div className="acc-name">{a.displayName || a.username}</div>
|
|
69
|
+
<div className="acc-handle">{a.platform} · @{a.username}</div>
|
|
70
|
+
</div>
|
|
71
|
+
<span className="badge badge-green">Active</span>
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
|
|
75
|
+
<hr className="divider" />
|
|
76
|
+
|
|
77
|
+
<div className="section-title mb8">Quick Actions</div>
|
|
78
|
+
<div className="row">
|
|
79
|
+
<button className="btn btn-primary" onClick={() => onNavigate('compose')}>✏️ New Post</button>
|
|
80
|
+
<button className="btn btn-secondary" onClick={() => onNavigate('scheduled')}>📅 View Scheduled</button>
|
|
81
|
+
<button className="btn btn-secondary" onClick={() => onNavigate('queues')}>🔄 Manage Queues</button>
|
|
82
|
+
<button className="btn btn-secondary" onClick={() => onNavigate('inbox')}>💬 Inbox</button>
|
|
83
|
+
<button className="btn btn-secondary" onClick={() => onNavigate('analytics')}>📊 Analytics</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { api, PROFILE_ID } from '../api.js';
|
|
3
|
+
import { useApp } from '../App.jsx';
|
|
4
|
+
|
|
5
|
+
export default function Inbox() {
|
|
6
|
+
const { showToast } = useApp();
|
|
7
|
+
const [conversations, setConversations] = useState([]);
|
|
8
|
+
const [loading, setLoading] = useState(true);
|
|
9
|
+
const [selected, setSelected] = useState(null);
|
|
10
|
+
const [thread, setThread] = useState(null);
|
|
11
|
+
const [threadLoading, setThreadLoading] = useState(false);
|
|
12
|
+
const [reply, setReply] = useState('');
|
|
13
|
+
const [sending, setSending] = useState(false);
|
|
14
|
+
|
|
15
|
+
const loadInbox = useCallback(() => {
|
|
16
|
+
if (!PROFILE_ID) { setLoading(false); return; }
|
|
17
|
+
api.getInbox(PROFILE_ID)
|
|
18
|
+
.then(d => setConversations(d.conversations || d.inbox || []))
|
|
19
|
+
.catch(e => showToast(e.message, false))
|
|
20
|
+
.finally(() => setLoading(false));
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
useEffect(loadInbox, [loadInbox]);
|
|
24
|
+
|
|
25
|
+
const selectConv = async (conv) => {
|
|
26
|
+
setSelected(conv);
|
|
27
|
+
setThread(null);
|
|
28
|
+
setReply('');
|
|
29
|
+
setThreadLoading(true);
|
|
30
|
+
try {
|
|
31
|
+
const data = await api.getConversation(conv._id || conv.id);
|
|
32
|
+
setThread(data);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
showToast(e.message, false);
|
|
35
|
+
} finally {
|
|
36
|
+
setThreadLoading(false);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const sendReply = async () => {
|
|
41
|
+
if (!reply.trim()) return;
|
|
42
|
+
if (!selected) return;
|
|
43
|
+
setSending(true);
|
|
44
|
+
try {
|
|
45
|
+
await api.replyConversation(selected._id || selected.id, { content: reply });
|
|
46
|
+
showToast('Reply sent ✓');
|
|
47
|
+
setReply('');
|
|
48
|
+
selectConv(selected);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
showToast(e.message, false);
|
|
51
|
+
} finally {
|
|
52
|
+
setSending(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (loading) return <div className="loading-row"><span className="spinner" />Loading inbox…</div>;
|
|
57
|
+
|
|
58
|
+
if (!conversations.length) return (
|
|
59
|
+
<div className="empty">
|
|
60
|
+
<div className="empty-icon">💬</div>
|
|
61
|
+
<div className="empty-msg">Inbox is empty. DMs, comments, and reviews will appear here.</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const msgs = thread?.messages || thread?.replies || [];
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="inbox-layout">
|
|
69
|
+
<div className="inbox-list">
|
|
70
|
+
{conversations.map(c => {
|
|
71
|
+
const id = c._id || c.id;
|
|
72
|
+
const isSelected = selected && (selected._id || selected.id) === id;
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
key={id}
|
|
76
|
+
className={`inbox-item ${isSelected ? 'selected' : ''}`}
|
|
77
|
+
onClick={() => selectConv(c)}
|
|
78
|
+
>
|
|
79
|
+
<div className="inbox-platform">{c.platform} · {c.type || 'message'}</div>
|
|
80
|
+
<div className="acc-name" style={{ marginBottom: 3 }}>{c.from || c.author || 'Unknown'}</div>
|
|
81
|
+
<div className="inbox-preview">{c.preview || c.lastMessage || '…'}</div>
|
|
82
|
+
{c.updatedAt && (
|
|
83
|
+
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4 }}>
|
|
84
|
+
{new Date(c.updatedAt).toLocaleDateString()}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className="inbox-thread">
|
|
93
|
+
{!selected && (
|
|
94
|
+
<div className="empty" style={{ margin: 'auto' }}>
|
|
95
|
+
<div className="empty-icon">💬</div>
|
|
96
|
+
<div className="empty-msg">Select a conversation</div>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{selected && (
|
|
101
|
+
<>
|
|
102
|
+
<div style={{ padding: '14px 18px', borderBottom: '1px solid var(--border)', background: 'var(--surface)' }}>
|
|
103
|
+
<div style={{ fontWeight: 600, fontSize: 13 }}>{selected.from || selected.author}</div>
|
|
104
|
+
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{selected.platform} · {selected._id || selected.id}</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div className="thread-msgs">
|
|
108
|
+
{threadLoading && <div className="loading-row"><span className="spinner" />Loading thread…</div>}
|
|
109
|
+
{!threadLoading && !msgs.length && <div style={{ color: 'var(--muted)', fontSize: 13 }}>No messages in thread yet.</div>}
|
|
110
|
+
{msgs.map((m, i) => {
|
|
111
|
+
const isOut = m.direction === 'outbound' || m.isOwn;
|
|
112
|
+
return (
|
|
113
|
+
<div key={i}>
|
|
114
|
+
<div className={`msg-meta ${isOut ? '' : ''}`} style={{ textAlign: isOut ? 'right' : 'left' }}>
|
|
115
|
+
{m.author || (isOut ? 'You' : selected.from)} · {m.createdAt ? new Date(m.createdAt).toLocaleTimeString() : ''}
|
|
116
|
+
</div>
|
|
117
|
+
<div className={`msg ${isOut ? 'msg-out' : 'msg-in'}`}>
|
|
118
|
+
{m.content || m.text || m.body}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div className="thread-reply">
|
|
126
|
+
<textarea
|
|
127
|
+
placeholder="Type a reply…"
|
|
128
|
+
value={reply}
|
|
129
|
+
onChange={e => setReply(e.target.value)}
|
|
130
|
+
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) sendReply(); }}
|
|
131
|
+
/>
|
|
132
|
+
<div className="col" style={{ gap: 6, justifyContent: 'flex-end' }}>
|
|
133
|
+
<button className="btn btn-primary btn-sm" onClick={sendReply} disabled={sending || !reply.trim()}>
|
|
134
|
+
{sending ? <span className="spinner" /> : 'Send'}
|
|
135
|
+
</button>
|
|
136
|
+
<span style={{ fontSize: 10, color: 'var(--muted)', textAlign: 'center' }}>⌘↵</span>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { api, PROFILE_ID } from '../api.js';
|
|
3
|
+
import { useApp } from '../App.jsx';
|
|
4
|
+
|
|
5
|
+
const DAYS = ['mon','tue','wed','thu','fri','sat','sun'];
|
|
6
|
+
const ALL_PLATS = ['twitter','linkedin','instagram','facebook','tiktok','youtube','bluesky','threads','reddit','pinterest','telegram','whatsapp'];
|
|
7
|
+
|
|
8
|
+
const emptySlot = () => ({ day: 'mon', time: '09:00', platforms: [] });
|
|
9
|
+
|
|
10
|
+
export default function Queues() {
|
|
11
|
+
const { accounts, showToast } = useApp();
|
|
12
|
+
const [queues, setQueues] = useState([]);
|
|
13
|
+
const [loading, setLoading] = useState(true);
|
|
14
|
+
const [creating, setCreating] = useState(false);
|
|
15
|
+
const [deleting, setDeleting] = useState(null);
|
|
16
|
+
const [showForm, setShowForm] = useState(false);
|
|
17
|
+
|
|
18
|
+
const [name, setName] = useState('');
|
|
19
|
+
const [timezone, setTimezone] = useState('America/New_York');
|
|
20
|
+
const [slots, setSlots] = useState([emptySlot()]);
|
|
21
|
+
|
|
22
|
+
const availPlats = accounts.length
|
|
23
|
+
? [...new Set(accounts.map(a => a.platform))]
|
|
24
|
+
: ALL_PLATS;
|
|
25
|
+
|
|
26
|
+
const load = useCallback(() => {
|
|
27
|
+
if (!PROFILE_ID) { setLoading(false); return; }
|
|
28
|
+
api.getQueues(PROFILE_ID)
|
|
29
|
+
.then(d => setQueues(d.queues || []))
|
|
30
|
+
.catch(e => showToast(e.message, false))
|
|
31
|
+
.finally(() => setLoading(false));
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
useEffect(load, [load]);
|
|
35
|
+
|
|
36
|
+
const addSlot = () => setSlots(s => [...s, emptySlot()]);
|
|
37
|
+
const removeSlot = (i) => setSlots(s => s.filter((_, j) => j !== i));
|
|
38
|
+
const updateSlot = (i, key, val) => setSlots(s => s.map((sl, j) => j !== i ? sl : { ...sl, [key]: val }));
|
|
39
|
+
const toggleSlotPlat = (i, p) => {
|
|
40
|
+
const sl = slots[i];
|
|
41
|
+
const plats = sl.platforms.includes(p) ? sl.platforms.filter(x => x !== p) : [...sl.platforms, p];
|
|
42
|
+
updateSlot(i, 'platforms', plats);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const create = async () => {
|
|
46
|
+
if (!name.trim()) { showToast('Enter a queue name', false); return; }
|
|
47
|
+
const validSlots = slots.filter(s => s.platforms.length);
|
|
48
|
+
if (!validSlots.length) { showToast('Each slot needs at least one platform', false); return; }
|
|
49
|
+
setCreating(true);
|
|
50
|
+
try {
|
|
51
|
+
await api.createQueue({ profileId: PROFILE_ID, name, timezone, slots: validSlots });
|
|
52
|
+
showToast('Queue created ✓');
|
|
53
|
+
setName(''); setSlots([emptySlot()]); setShowForm(false);
|
|
54
|
+
load();
|
|
55
|
+
} catch (e) {
|
|
56
|
+
showToast(e.message, false);
|
|
57
|
+
} finally {
|
|
58
|
+
setCreating(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const deleteQueue = async (id) => {
|
|
63
|
+
if (!confirm('Delete this queue? Already-scheduled posts remain.')) return;
|
|
64
|
+
setDeleting(id);
|
|
65
|
+
try {
|
|
66
|
+
await api.deleteQueue(id);
|
|
67
|
+
showToast('Queue deleted');
|
|
68
|
+
load();
|
|
69
|
+
} catch (e) {
|
|
70
|
+
showToast(e.message, false);
|
|
71
|
+
} finally {
|
|
72
|
+
setDeleting(null);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (loading) return <div className="loading-row"><span className="spinner" />Loading queues…</div>;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div>
|
|
80
|
+
<div className="row mb16" style={{ justifyContent: 'space-between' }}>
|
|
81
|
+
<div className="section-title" style={{ marginBottom: 0 }}>{queues.length} Queue{queues.length !== 1 ? 's' : ''}</div>
|
|
82
|
+
<button className="btn btn-primary btn-sm" onClick={() => setShowForm(f => !f)}>
|
|
83
|
+
{showForm ? '✕ Cancel' : '+ New Queue'}
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{showForm && (
|
|
88
|
+
<div className="card mb16">
|
|
89
|
+
<div className="section-title mb16">New Recurring Queue</div>
|
|
90
|
+
<div className="field">
|
|
91
|
+
<label>Queue Name</label>
|
|
92
|
+
<input className="input" placeholder="e.g. weekly-evergreen" value={name} onChange={e => setName(e.target.value)} />
|
|
93
|
+
</div>
|
|
94
|
+
<div className="field">
|
|
95
|
+
<label>Timezone</label>
|
|
96
|
+
<input className="input" value={timezone} onChange={e => setTimezone(e.target.value)} />
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div className="field">
|
|
100
|
+
<label>Time Slots</label>
|
|
101
|
+
{slots.map((sl, i) => (
|
|
102
|
+
<div key={i} style={{ background: '#09090b', border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 8 }}>
|
|
103
|
+
<div className="row mb8">
|
|
104
|
+
<select className="select" style={{ width: 90 }} value={sl.day} onChange={e => updateSlot(i, 'day', e.target.value)}>
|
|
105
|
+
{DAYS.map(d => <option key={d}>{d}</option>)}
|
|
106
|
+
</select>
|
|
107
|
+
<input className="input" type="time" style={{ width: 110 }} value={sl.time} onChange={e => updateSlot(i, 'time', e.target.value)} />
|
|
108
|
+
<button className="btn btn-danger btn-xs" style={{ marginLeft: 'auto' }} onClick={() => removeSlot(i)} disabled={slots.length === 1}>✕</button>
|
|
109
|
+
</div>
|
|
110
|
+
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 5 }}>Platforms</div>
|
|
111
|
+
<div className="plat-row">
|
|
112
|
+
{availPlats.map(p => (
|
|
113
|
+
<span key={p} className={`plat-toggle ${sl.platforms.includes(p) ? 'selected' : ''}`} onClick={() => toggleSlotPlat(i, p)}>{p}</span>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
<button className="btn btn-ghost btn-sm mt8" onClick={addSlot}>+ Add Slot</button>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div className="row-end">
|
|
122
|
+
<button className="btn btn-primary" onClick={create} disabled={creating}>
|
|
123
|
+
{creating ? <><span className="spinner" style={{ marginRight: 7 }} />Creating…</> : 'Create Queue'}
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{!queues.length && !showForm && (
|
|
130
|
+
<div className="empty">
|
|
131
|
+
<div className="empty-icon">🔄</div>
|
|
132
|
+
<div className="empty-msg">No queues yet. Create one to auto-schedule posts into recurring time slots.</div>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{queues.map(q => (
|
|
137
|
+
<div key={q._id} className="card mb16">
|
|
138
|
+
<div className="row mb8" style={{ justifyContent: 'space-between' }}>
|
|
139
|
+
<div>
|
|
140
|
+
<span style={{ fontWeight: 600 }}>{q.name}</span>
|
|
141
|
+
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 10 }}>{q.timezone}</span>
|
|
142
|
+
</div>
|
|
143
|
+
<button className="btn btn-danger btn-sm" onClick={() => deleteQueue(q._id)} disabled={deleting === q._id}>
|
|
144
|
+
{deleting === q._id ? '…' : 'Delete'}
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
<div className="table-wrap">
|
|
148
|
+
<table>
|
|
149
|
+
<thead>
|
|
150
|
+
<tr><th>Day</th><th>Time</th><th>Platforms</th></tr>
|
|
151
|
+
</thead>
|
|
152
|
+
<tbody>
|
|
153
|
+
{(q.slots || []).map((sl, i) => (
|
|
154
|
+
<tr key={i}>
|
|
155
|
+
<td style={{ textTransform: 'capitalize' }}>{sl.day}</td>
|
|
156
|
+
<td>{sl.time}</td>
|
|
157
|
+
<td>{(sl.platforms || []).join(', ')}</td>
|
|
158
|
+
</tr>
|
|
159
|
+
))}
|
|
160
|
+
</tbody>
|
|
161
|
+
</table>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { api, PROFILE_ID } from '../api.js';
|
|
3
|
+
import { useApp } from '../App.jsx';
|
|
4
|
+
|
|
5
|
+
export default function Scheduled() {
|
|
6
|
+
const { showToast } = useApp();
|
|
7
|
+
const [posts, setPosts] = useState([]);
|
|
8
|
+
const [loading, setLoading] = useState(true);
|
|
9
|
+
const [deleting, setDeleting] = useState(null);
|
|
10
|
+
|
|
11
|
+
const load = useCallback(() => {
|
|
12
|
+
if (!PROFILE_ID) { setLoading(false); return; }
|
|
13
|
+
setLoading(true);
|
|
14
|
+
api.getPosts(PROFILE_ID, 'scheduled')
|
|
15
|
+
.then(d => setPosts(d.posts || []))
|
|
16
|
+
.catch(e => showToast(e.message, false))
|
|
17
|
+
.finally(() => setLoading(false));
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
useEffect(load, [load]);
|
|
21
|
+
|
|
22
|
+
const deletePost = async (id) => {
|
|
23
|
+
if (!confirm('Unschedule this post?')) return;
|
|
24
|
+
setDeleting(id);
|
|
25
|
+
try {
|
|
26
|
+
await api.deletePost(id);
|
|
27
|
+
showToast('Post unscheduled');
|
|
28
|
+
load();
|
|
29
|
+
} catch (e) {
|
|
30
|
+
showToast(e.message, false);
|
|
31
|
+
} finally {
|
|
32
|
+
setDeleting(null);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (loading) return <div className="loading-row"><span className="spinner" />Loading scheduled posts…</div>;
|
|
37
|
+
|
|
38
|
+
if (!posts.length) return (
|
|
39
|
+
<div className="empty">
|
|
40
|
+
<div className="empty-icon">📅</div>
|
|
41
|
+
<div className="empty-msg">No scheduled posts. Use Compose to schedule your first post.</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div>
|
|
47
|
+
<div className="row mb16" style={{ justifyContent: 'space-between' }}>
|
|
48
|
+
<div className="section-title" style={{ marginBottom: 0 }}>{posts.length} Scheduled Post{posts.length !== 1 ? 's' : ''}</div>
|
|
49
|
+
<button className="btn btn-ghost btn-sm" onClick={load}>↻ Refresh</button>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
{posts.map(p => (
|
|
53
|
+
<div key={p._id || p.id} className="post-card">
|
|
54
|
+
<div className="post-meta">
|
|
55
|
+
{(p.platforms || []).map(pl => (
|
|
56
|
+
<span key={pl.platform} className="badge badge-neutral" style={{ marginRight: 2 }}>{pl.platform}</span>
|
|
57
|
+
))}
|
|
58
|
+
<span style={{ fontSize: 12, color: 'var(--muted)', marginLeft: 4 }}>
|
|
59
|
+
{p.scheduledFor ? new Date(p.scheduledFor).toLocaleString() : 'Queued'}
|
|
60
|
+
</span>
|
|
61
|
+
<span className="badge badge-blue" style={{ marginLeft: 'auto' }}>Scheduled</span>
|
|
62
|
+
</div>
|
|
63
|
+
<div className="post-content">{p.content}</div>
|
|
64
|
+
{p.media?.length > 0 && (
|
|
65
|
+
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 6 }}>
|
|
66
|
+
📎 {p.media.length} media file{p.media.length > 1 ? 's' : ''}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
<div className="post-actions">
|
|
70
|
+
<span style={{ fontSize: 11, color: 'var(--muted)', alignSelf: 'center', fontFamily: 'monospace' }}>
|
|
71
|
+
{p._id || p.id}
|
|
72
|
+
</span>
|
|
73
|
+
<button
|
|
74
|
+
className="btn btn-danger btn-xs"
|
|
75
|
+
onClick={() => deletePost(p._id || p.id)}
|
|
76
|
+
disabled={deleting === (p._id || p.id)}
|
|
77
|
+
>
|
|
78
|
+
{deleting === (p._id || p.id) ? '…' : 'Unschedule'}
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|