Redesign social scheduler as inline-edit card grid
- 3-col grid (col-xl-4 col-md-6) replacing left/right split layout - First card is always the "new post" composer in edit state - Clicking a post card (or its pencil icon) opens inline editing on that card - Multiple cards can be in edit state simultaneously - Autosave for existing posts (debounced 2s) with saving/saved/error indicator - Post cards show: datetime + status (top left), action icons (top right), text body, image (centered, max-height = card width), platform badges - Image max-height constrained to card width via JS after render - Emoji picker moved to shared fixed floating panel - Per-card error display; no global form state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1c43c6c432
commit
e52f90445b
3 changed files with 424 additions and 365 deletions
|
|
@ -448,7 +448,8 @@ pre { color: #e6edf3; }
|
|||
}
|
||||
|
||||
/* Social Scheduler */
|
||||
.social-emoji-picker { min-width: 260px; max-height: 280px; overflow-y: auto; background: var(--hm-surface); border-color: var(--hm-border); }
|
||||
/* Emoji picker — now a floating fixed panel */
|
||||
.social-emoji-panel { position: fixed; z-index: 9999; width: 270px; max-height: 240px; overflow-y: auto; }
|
||||
.social-emoji-grid { font-size: 1.2rem; line-height: 1.6; }
|
||||
.social-emoji-cat { font-size: .65rem; text-transform: uppercase; letter-spacing: .05em; color: var(--hm-muted); }
|
||||
.social-emoji-grid span.emoji-btn { cursor: pointer; padding: 1px 2px; border-radius: 3px; }
|
||||
|
|
@ -473,12 +474,23 @@ pre { color: #e6edf3; }
|
|||
.social-pill:hover .social-pill-img { color: rgba(248,81,73,.9); }
|
||||
.social-pill.img-on .social-pill-img { color: #3fb950; }
|
||||
|
||||
.social-post-card .card-body { border-left: 3px solid transparent; border-radius: inherit; }
|
||||
.social-post-card.status-pending .card-body { border-left-color: #e3b341; }
|
||||
.social-post-card.status-done .card-body { border-left-color: #3fb950; }
|
||||
.social-post-card.status-partial .card-body { border-left-color: #e3b341; }
|
||||
.social-post-card.status-failed .card-body { border-left-color: #f85149; }
|
||||
/* Social post cards (new grid design) */
|
||||
.sc-card { transition: border-color .15s; }
|
||||
.sc-new-card { border-color: rgba(88,101,242,.5) !important; }
|
||||
.sc-editing { border-color: #8b949e !important; }
|
||||
.sc-post-card.status-pending { border-left: 3px solid #e3b341; }
|
||||
.sc-post-card.status-done { border-left: 3px solid #3fb950; }
|
||||
.sc-post-card.status-sent { border-left: 3px solid #3fb950; }
|
||||
.sc-post-card.status-partial { border-left: 3px solid #e3b341; }
|
||||
.sc-post-card.status-failed { border-left: 3px solid #f85149; }
|
||||
.sc-view-pane { cursor: pointer; }
|
||||
.sc-view-pane:hover { background: rgba(255,255,255,.02); border-radius: inherit; }
|
||||
|
||||
/* Post image — constrained to card width (max-height set via JS) */
|
||||
.sc-img-wrap { width: 100%; display: flex; justify-content: center; align-items: center; overflow: hidden; background: rgba(0,0,0,.12); border-radius: 6px; }
|
||||
.sc-post-img { max-width: 100%; max-height: 100%; height: auto; object-fit: contain; display: block; }
|
||||
|
||||
.social-char-warn { color: #e3b341 !important; }
|
||||
.social-char-over { color: #f85149 !important; }
|
||||
.sc-save-indicator { min-width: 4rem; text-align: right; }
|
||||
|
||||
|
|
|
|||
|
|
@ -3822,28 +3822,24 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
// ── Social Scheduler ─────────────────────────────────────────────────────────
|
||||
(function () {
|
||||
const page = document.getElementById('socialPage');
|
||||
if (!page) return;
|
||||
if (!document.getElementById('socialPage')) return;
|
||||
|
||||
let _targets = [];
|
||||
let _filter = 'pending';
|
||||
let _hasImage = false;
|
||||
let _targets = [];
|
||||
let _filter = 'pending';
|
||||
|
||||
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
const CHAR_LIMIT = 500;
|
||||
const AUTOSAVE_DELAY = 2000;
|
||||
|
||||
// Returns 'YYYY-MM-DDTHH:MM' in browser local time — correct value for datetime-local inputs
|
||||
function localDTStr(d) {
|
||||
const p = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function defaultScheduleTime() {
|
||||
const d = new Date(Date.now() + 5 * 60 * 1000);
|
||||
d.setSeconds(0, 0);
|
||||
return localDTStr(d);
|
||||
}
|
||||
|
||||
// Relative time — ts is local-time string (no Z), browser treats it as local
|
||||
function relTime(ts) {
|
||||
const diff = (new Date(ts.replace(' ', 'T')) - Date.now()) / 1000;
|
||||
const abs = Math.abs(diff);
|
||||
|
|
@ -3854,49 +3850,57 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||
if (abs < 86400) return pre + Math.round(abs / 3600) + ' h' + suf;
|
||||
return pre + Math.round(abs / 86400) + ' d' + suf;
|
||||
}
|
||||
function fmtDT(ts) {
|
||||
return ts.substring(0, 16).replace('T', ' ');
|
||||
}
|
||||
|
||||
// ── Emoji picker ─────────────────────────────────────────────────────────────
|
||||
document.querySelector('.social-emoji-grid').addEventListener('click', e => {
|
||||
const ch = e.target.closest('.emoji-btn')?.dataset.e;
|
||||
if (!ch) return;
|
||||
const ta = document.getElementById('socialContent');
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
ta.value = ta.value.slice(0, start) + ch + ta.value.slice(end);
|
||||
ta.selectionStart = ta.selectionEnd = start + ch.length;
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
ta.focus();
|
||||
});
|
||||
const emojiPanel = document.getElementById('socialEmojiPanel');
|
||||
let _emojiTA = null;
|
||||
|
||||
// Wrap each space-separated emoji in a clickable span
|
||||
document.querySelectorAll('.social-emoji-grid > div:not(.social-emoji-cat)').forEach(row => {
|
||||
document.querySelectorAll('#socialEmojiPanel .social-emoji-grid > div:not(.social-emoji-cat)').forEach(row => {
|
||||
row.innerHTML = row.textContent.trim().split(/\s+/).map(e => `<span class="emoji-btn" data-e="${esc(e)}">${e}</span>`).join(' ');
|
||||
});
|
||||
|
||||
// ── Character counter ─────────────────────────────────────────────────────────
|
||||
const CHAR_LIMIT = 500;
|
||||
document.getElementById('socialContent').addEventListener('input', function () {
|
||||
const n = [...this.value].length;
|
||||
const el = document.getElementById('socialCharCount');
|
||||
el.textContent = `${n} / ${CHAR_LIMIT}`;
|
||||
el.className = 'small ' + (n > CHAR_LIMIT ? 'social-char-over' : n > CHAR_LIMIT * 0.9 ? 'social-char-warn' : 'text-muted');
|
||||
emojiPanel.querySelector('.social-emoji-grid').addEventListener('click', e => {
|
||||
const ch = e.target.closest('.emoji-btn')?.dataset.e;
|
||||
if (!ch || !_emojiTA) return;
|
||||
const ta = _emojiTA, s = ta.selectionStart, end = ta.selectionEnd;
|
||||
ta.value = ta.value.slice(0, s) + ch + ta.value.slice(end);
|
||||
ta.selectionStart = ta.selectionEnd = s + ch.length;
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
emojiPanel.classList.add('d-none');
|
||||
ta.focus();
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
if (!emojiPanel.classList.contains('d-none') &&
|
||||
!e.target.closest('#socialEmojiPanel') &&
|
||||
!e.target.closest('.sc-emoji-btn')) {
|
||||
emojiPanel.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
function showEmojiPanel(btn, ta) {
|
||||
if (!emojiPanel.classList.contains('d-none') && _emojiTA === ta) {
|
||||
emojiPanel.classList.add('d-none'); return;
|
||||
}
|
||||
_emojiTA = ta;
|
||||
const r = btn.getBoundingClientRect();
|
||||
emojiPanel.style.top = (r.bottom + 4) + 'px';
|
||||
emojiPanel.style.left = Math.min(r.left, window.innerWidth - 280) + 'px';
|
||||
emojiPanel.classList.remove('d-none');
|
||||
}
|
||||
|
||||
// ── Targets ──────────────────────────────────────────────────────────────────
|
||||
async function loadTargets() {
|
||||
const r = await fetch('/api/social?action=targets');
|
||||
const d = await r.json();
|
||||
_targets = d.groups || [];
|
||||
renderTargets(null);
|
||||
}
|
||||
|
||||
function renderTargets(prefill) {
|
||||
const el = document.getElementById('socialTargets');
|
||||
function renderTargetsInto(container, prefill, hasImage) {
|
||||
if (!_targets.length) {
|
||||
el.innerHTML = '<div class="text-muted small">No eligible projects configured. <a href="/settings">Go to Settings</a> to enable projects.</div>';
|
||||
container.innerHTML = '<div class="text-muted small">No platforms configured. <a href="/settings">Settings →</a></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const pills = _targets.flatMap(group =>
|
||||
['discord', 'mastodon', 'linkedin'].flatMap(platform =>
|
||||
(group.targets[platform] || []).map(t => {
|
||||
|
|
@ -3906,270 +3910,387 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||
data-pid="${group.project_id}" data-platform="${platform}" data-key="${esc(t.key)}"
|
||||
title="${esc(group.project_name)}">
|
||||
<i class="bi bi-${platform}"></i>${esc(t.label)}
|
||||
<span class="social-pill-img${_hasImage ? '' : ' d-none'}" title="Include image"><i class="bi bi-image-fill"></i></span>
|
||||
<span class="social-pill-img${hasImage ? '' : ' d-none'}" title="Include image"><i class="bi bi-image-fill"></i></span>
|
||||
</div>`;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
el.innerHTML = `<div class="d-flex flex-wrap gap-2">${pills.join('')}</div>`;
|
||||
|
||||
el.querySelectorAll('.social-pill').forEach(pill => {
|
||||
container.innerHTML = `<div class="d-flex flex-wrap gap-2">${pills.join('')}</div>`;
|
||||
container.querySelectorAll('.social-pill').forEach(pill => {
|
||||
pill.addEventListener('click', e => {
|
||||
if (e.target.closest('.social-pill-img')) {
|
||||
if (pill.classList.contains('active')) pill.classList.toggle('img-on');
|
||||
return;
|
||||
}
|
||||
pill.classList.toggle('active');
|
||||
if (pill.classList.contains('active') && _hasImage) pill.classList.add('img-on');
|
||||
else if (!pill.classList.contains('active')) pill.classList.remove('img-on');
|
||||
const imgVisible = !container.querySelector('.social-pill-img.d-none');
|
||||
if (pill.classList.contains('active') && imgVisible) pill.classList.add('img-on');
|
||||
else if (!pill.classList.contains('active')) pill.classList.remove('img-on');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleImageCols(show) {
|
||||
_hasImage = show;
|
||||
page.querySelectorAll('.social-pill-img').forEach(el => el.classList.toggle('d-none', !show));
|
||||
if (show) page.querySelectorAll('.social-pill.active').forEach(p => p.classList.add('img-on'));
|
||||
if (!show) page.querySelectorAll('.social-pill').forEach(p => p.classList.remove('img-on'));
|
||||
}
|
||||
|
||||
function collectTargets() {
|
||||
const targets = [];
|
||||
page.querySelectorAll('.social-pill.active').forEach(pill => {
|
||||
targets.push({
|
||||
function collectTargetsFrom(container) {
|
||||
const out = [];
|
||||
container.querySelectorAll('.social-pill.active').forEach(pill => {
|
||||
out.push({
|
||||
project_id: +pill.dataset.pid,
|
||||
platform: pill.dataset.platform,
|
||||
target_key: pill.dataset.key,
|
||||
include_image: pill.classList.contains('img-on') ? 1 : 0,
|
||||
});
|
||||
});
|
||||
return targets;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Image upload ─────────────────────────────────────────────────────────────
|
||||
document.getElementById('socialUploadBtn').addEventListener('click', () => {
|
||||
document.getElementById('socialImageInput').click();
|
||||
});
|
||||
function toggleImageInCard(card, show) {
|
||||
card.querySelectorAll('.social-pill-img').forEach(el => el.classList.toggle('d-none', !show));
|
||||
if (show) card.querySelectorAll('.social-pill.active').forEach(p => p.classList.add('img-on'));
|
||||
if (!show) card.querySelectorAll('.social-pill').forEach(p => p.classList.remove('img-on'));
|
||||
}
|
||||
|
||||
document.getElementById('socialImageInput').addEventListener('change', async function () {
|
||||
const file = this.files[0];
|
||||
if (!file) return;
|
||||
// ── Shared image upload ───────────────────────────────────────────────────────
|
||||
const imgInput = document.getElementById('socialImageInput');
|
||||
let _uploadCard = null;
|
||||
|
||||
imgInput.addEventListener('change', async function () {
|
||||
const card = _uploadCard;
|
||||
if (!this.files[0] || !card) return;
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
fd.append('image', this.files[0]);
|
||||
this.value = '';
|
||||
const r = await fetch('/api/social_upload', { method: 'POST', body: fd });
|
||||
const d = await r.json();
|
||||
if (d.error) { showSocialError(d.error); return; }
|
||||
document.getElementById('socialImagePath').value = d.path;
|
||||
document.getElementById('socialImageThumb').src = d.url;
|
||||
document.getElementById('socialImagePreview').classList.remove('d-none');
|
||||
document.getElementById('socialRemoveImageBtn').classList.remove('d-none');
|
||||
toggleImageCols(true);
|
||||
this.value = '';
|
||||
if (d.error) { showCardError(card, d.error); return; }
|
||||
card.querySelector('.sc-img-path').value = d.path;
|
||||
card.querySelector('.sc-img-thumb').src = d.url;
|
||||
card.querySelector('.sc-img-preview').classList.remove('d-none');
|
||||
card.querySelector('.sc-remove-img-btn').classList.remove('d-none');
|
||||
toggleImageInCard(card, true);
|
||||
scheduleAutosave(card);
|
||||
});
|
||||
|
||||
document.getElementById('socialRemoveImageBtn').addEventListener('click', () => {
|
||||
document.getElementById('socialImagePath').value = '';
|
||||
document.getElementById('socialImageThumb').src = '';
|
||||
document.getElementById('socialImagePreview').classList.add('d-none');
|
||||
document.getElementById('socialRemoveImageBtn').classList.add('d-none');
|
||||
toggleImageCols(false);
|
||||
});
|
||||
// ── Status badge ─────────────────────────────────────────────────────────────
|
||||
const STATUS_BADGE = s => {
|
||||
const m = { pending: ['warning text-dark','clock'], sent: ['success','check-circle'], done: ['success','check-circle-fill'], failed: ['danger','x-circle'], partial: ['warning text-dark','exclamation-circle'], processing: ['info text-dark','arrow-repeat'] };
|
||||
const [cls, icon] = m[s] || ['secondary','dot'];
|
||||
return `<span class="badge bg-${cls} fw-normal">${esc(s)}</span>`;
|
||||
};
|
||||
|
||||
// ── Form submit ───────────────────────────────────────────────────────────────
|
||||
document.getElementById('socialSubmitBtn').addEventListener('click', async () => {
|
||||
const editId = document.getElementById('socialEditId').value;
|
||||
const content = document.getElementById('socialContent').value.trim();
|
||||
const scheduledAt = document.getElementById('socialScheduledAt').value;
|
||||
const imagePath = document.getElementById('socialImagePath').value;
|
||||
const targets = collectTargets();
|
||||
// ── Build view pane HTML ──────────────────────────────────────────────────────
|
||||
function buildViewPane(post) {
|
||||
const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' };
|
||||
const canRetry = post.targets?.some(t => t.status === 'failed');
|
||||
|
||||
hideSocialError();
|
||||
if (!content) { showSocialError('Content is required'); return; }
|
||||
if (!scheduledAt) { showSocialError('Scheduled time is required'); return; }
|
||||
if (!targets.length) { showSocialError('Select at least one platform target'); return; }
|
||||
const platBadges = (post.targets || []).map(t => {
|
||||
const sCls = { sent: 'bg-success', done: 'bg-success', failed: 'bg-danger', pending: 'bg-warning text-dark' };
|
||||
return `<span class="badge border social-platform-${esc(t.platform)} fw-normal d-inline-flex align-items-center gap-1">
|
||||
<i class="bi ${platformIcon[t.platform] || 'bi-dot'}"></i>${esc(t.target_key)}
|
||||
<span class="badge ${sCls[t.status] || 'bg-secondary'} ms-1" style="font-size:.65em">${esc(t.status)}</span>
|
||||
${t.error ? `<i class="bi bi-exclamation-circle text-danger ms-1" title="${esc(t.error)}"></i>` : ''}
|
||||
</span>`;
|
||||
}).join(' ');
|
||||
|
||||
const body = { action: editId ? 'update' : 'create', content, scheduled_at: scheduledAt, image_path: imagePath, targets };
|
||||
if (editId) body.id = +editId;
|
||||
return `<div class="sc-view-pane p-3 d-flex flex-column h-100">
|
||||
<div class="d-flex align-items-start gap-2 mb-2">
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
<div class="small text-muted">${esc(fmtDT(post.scheduled_at))}</div>
|
||||
<div>${STATUS_BADGE(post.status)}</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1 flex-shrink-0">
|
||||
${canRetry ? `<button class="btn btn-xs btn-outline-warning sc-retry-btn" title="Retry"><i class="bi bi-arrow-repeat"></i></button>` : ''}
|
||||
<button class="btn btn-xs btn-outline-secondary sc-edit-btn" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||
<button class="btn btn-xs btn-outline-secondary sc-dup-btn" title="Duplicate"><i class="bi bi-copy"></i></button>
|
||||
<button class="btn btn-xs btn-outline-danger sc-del-btn" title="Delete"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc-text small mb-2 flex-grow-1" style="white-space:pre-wrap;word-break:break-word">${esc(post.content)}</div>
|
||||
${post.image_url ? `<div class="sc-img-wrap mb-2"><img src="${esc(post.image_url)}" class="sc-post-img" alt=""></div>` : ''}
|
||||
<div class="d-flex flex-wrap gap-1 mt-auto">${platBadges}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Build edit pane HTML ──────────────────────────────────────────────────────
|
||||
function buildEditPane(post) {
|
||||
const isNew = !post;
|
||||
const dt = post ? post.scheduled_at.replace(' ', 'T').substring(0, 16) : defaultScheduleTime();
|
||||
const content = post?.content || '';
|
||||
const imgPath = post?.image_path || '';
|
||||
const imgUrl = post?.image_url || '';
|
||||
|
||||
return `<div class="sc-edit-pane p-3">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<input type="datetime-local" class="sc-dt form-control form-control-sm flex-grow-1" value="${esc(dt)}">
|
||||
<span class="sc-save-indicator small"></span>
|
||||
<button class="btn btn-sm btn-primary sc-save-btn">${isNew ? '<i class="bi bi-calendar-plus me-1"></i>Schedule' : '<i class="bi bi-check-lg me-1"></i>Save'}</button>
|
||||
${!isNew ? '<button class="btn btn-sm btn-outline-secondary sc-cancel-btn">Cancel</button>' : ''}
|
||||
</div>
|
||||
<div class="position-relative mb-1">
|
||||
<textarea class="sc-content form-control form-control-sm font-monospace" rows="5"
|
||||
placeholder="What's on your mind?">${esc(content)}</textarea>
|
||||
<button type="button" class="sc-emoji-btn btn btn-xs btn-outline-secondary position-absolute"
|
||||
style="top:6px;right:6px;opacity:.6" title="Emoji">😊</button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
<span class="sc-char-count small text-muted">${[...content].length} / ${CHAR_LIMIT}</span>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div class="d-flex gap-1 mb-1">
|
||||
<button type="button" class="btn btn-xs btn-outline-secondary sc-upload-btn">
|
||||
<i class="bi bi-image me-1"></i>Image
|
||||
</button>
|
||||
<button type="button" class="btn btn-xs btn-outline-danger sc-remove-img-btn${imgPath ? '' : ' d-none'}">
|
||||
<i class="bi bi-x"></i> Remove
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" class="sc-img-path" value="${esc(imgPath)}">
|
||||
<div class="sc-img-preview${imgUrl ? '' : ' d-none'}">
|
||||
<img class="sc-img-thumb img-fluid rounded border" src="${esc(imgUrl)}" style="max-height:140px;object-fit:contain">
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc-targets mb-2"><div class="text-muted small">Loading…</div></div>
|
||||
<div class="sc-error alert alert-danger py-1 small d-none mb-0"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Autosave ──────────────────────────────────────────────────────────────────
|
||||
function setSaveIndicator(card, state) {
|
||||
const el = card.querySelector('.sc-save-indicator');
|
||||
if (!el) return;
|
||||
const map = { dirty: ['●', 'text-muted'], saving: ['saving…', 'text-info small'], saved: ['✓ saved', 'text-success small'], error: ['✗ error', 'text-danger small'] };
|
||||
const [txt, cls] = map[state] || ['', ''];
|
||||
el.textContent = txt;
|
||||
el.className = 'sc-save-indicator ' + cls;
|
||||
if (state === 'saved') setTimeout(() => { if (el.textContent === '✓ saved') el.textContent = ''; }, 2000);
|
||||
}
|
||||
|
||||
function scheduleAutosave(card) {
|
||||
if (!card._post) return; // don't autosave new post card
|
||||
setSaveIndicator(card, 'dirty');
|
||||
clearTimeout(card._autosaveTimer);
|
||||
card._autosaveTimer = setTimeout(() => doAutosave(card), AUTOSAVE_DELAY);
|
||||
}
|
||||
|
||||
async function doAutosave(card) {
|
||||
const post = card._post;
|
||||
if (!post) return;
|
||||
const content = card.querySelector('.sc-content').value.trim();
|
||||
const scheduledAt = card.querySelector('.sc-dt').value;
|
||||
const imagePath = card.querySelector('.sc-img-path').value;
|
||||
const targets = collectTargetsFrom(card.querySelector('.sc-targets'));
|
||||
if (!content || !scheduledAt || !targets.length) return;
|
||||
|
||||
setSaveIndicator(card, 'saving');
|
||||
const body = { action: 'update', id: post.id, content, scheduled_at: scheduledAt, image_path: imagePath, targets };
|
||||
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const d = await r.json();
|
||||
if (d.error) { setSaveIndicator(card, 'error'); showCardError(card, d.error); }
|
||||
else {
|
||||
setSaveIndicator(card, 'saved');
|
||||
// keep in-memory post up to date so cancel reverts to latest saved state
|
||||
card._post = { ...post, content, scheduled_at: scheduledAt.replace('T', ' '), image_path: imagePath };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init edit card ────────────────────────────────────────────────────────────
|
||||
function initEditCard(card, post) {
|
||||
card._post = post || null;
|
||||
|
||||
const textarea = card.querySelector('.sc-content');
|
||||
const charCount = card.querySelector('.sc-char-count');
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
const n = [...textarea.value].length;
|
||||
charCount.textContent = `${n} / ${CHAR_LIMIT}`;
|
||||
charCount.className = 'small ' + (n > CHAR_LIMIT ? 'social-char-over' : n > CHAR_LIMIT * 0.9 ? 'social-char-warn' : 'text-muted');
|
||||
scheduleAutosave(card);
|
||||
});
|
||||
|
||||
card.querySelector('.sc-dt').addEventListener('change', () => scheduleAutosave(card));
|
||||
|
||||
card.querySelector('.sc-emoji-btn').addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
showEmojiPanel(e.currentTarget, textarea);
|
||||
});
|
||||
|
||||
card.querySelector('.sc-upload-btn').addEventListener('click', () => {
|
||||
_uploadCard = card;
|
||||
imgInput.click();
|
||||
});
|
||||
|
||||
card.querySelector('.sc-remove-img-btn').addEventListener('click', () => {
|
||||
card.querySelector('.sc-img-path').value = '';
|
||||
card.querySelector('.sc-img-thumb').src = '';
|
||||
card.querySelector('.sc-img-preview').classList.add('d-none');
|
||||
card.querySelector('.sc-remove-img-btn').classList.add('d-none');
|
||||
toggleImageInCard(card, false);
|
||||
scheduleAutosave(card);
|
||||
});
|
||||
|
||||
card.querySelector('.sc-save-btn').addEventListener('click', () => submitCard(card, post));
|
||||
|
||||
card.querySelector('.sc-cancel-btn')?.addEventListener('click', () => {
|
||||
clearTimeout(card._autosaveTimer);
|
||||
exitEditMode(card);
|
||||
});
|
||||
|
||||
const targetsDiv = card.querySelector('.sc-targets');
|
||||
renderTargetsInto(targetsDiv, post?.targets || null, !!card.querySelector('.sc-img-path').value);
|
||||
|
||||
// wire target pill changes to autosave
|
||||
targetsDiv.addEventListener('click', () => scheduleAutosave(card));
|
||||
}
|
||||
|
||||
// ── Init view card ────────────────────────────────────────────────────────────
|
||||
function initViewCard(card, post) {
|
||||
card._post = post;
|
||||
|
||||
card.querySelector('.sc-view-pane').addEventListener('click', e => {
|
||||
if (e.target.closest('button')) return;
|
||||
enterEditMode(card, post);
|
||||
});
|
||||
|
||||
card.querySelector('.sc-edit-btn').addEventListener('click', () => enterEditMode(card, post));
|
||||
|
||||
card.querySelector('.sc-dup-btn').addEventListener('click', () => duplicatePost(post.id));
|
||||
|
||||
card.querySelector('.sc-retry-btn')?.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'retry', id: post.id }) });
|
||||
const d = await r.json();
|
||||
if (d.ok) loadPosts();
|
||||
});
|
||||
|
||||
const delBtn = card.querySelector('.sc-del-btn');
|
||||
delBtn.addEventListener('click', async () => {
|
||||
if (!delBtn._confirm) {
|
||||
delBtn._confirm = true;
|
||||
delBtn.innerHTML = 'Sure?';
|
||||
delBtn.classList.replace('btn-outline-danger', 'btn-danger');
|
||||
setTimeout(() => {
|
||||
if (delBtn._confirm) {
|
||||
delBtn._confirm = false;
|
||||
delBtn.innerHTML = '<i class="bi bi-trash"></i>';
|
||||
delBtn.classList.replace('btn-danger', 'btn-outline-danger');
|
||||
}
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
delBtn._confirm = false;
|
||||
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', id: post.id }) });
|
||||
const d = await r.json();
|
||||
if (d.ok) card.closest('[class*="col-"]').remove();
|
||||
else showCardError(card, d.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Enter / exit edit mode ────────────────────────────────────────────────────
|
||||
function enterEditMode(card, post) {
|
||||
card._post = post;
|
||||
card.classList.add('sc-editing');
|
||||
card.innerHTML = buildEditPane(post);
|
||||
initEditCard(card, post);
|
||||
constrainImages();
|
||||
}
|
||||
|
||||
function exitEditMode(card) {
|
||||
const post = card._post;
|
||||
card.classList.remove('sc-editing');
|
||||
card.innerHTML = buildViewPane(post);
|
||||
initViewCard(card, post);
|
||||
constrainImages();
|
||||
}
|
||||
|
||||
// ── Submit (explicit save) ────────────────────────────────────────────────────
|
||||
async function submitCard(card, post) {
|
||||
const content = card.querySelector('.sc-content').value.trim();
|
||||
const scheduledAt = card.querySelector('.sc-dt').value;
|
||||
const imagePath = card.querySelector('.sc-img-path').value;
|
||||
const targets = collectTargetsFrom(card.querySelector('.sc-targets'));
|
||||
const isNew = !post;
|
||||
|
||||
hideCardError(card);
|
||||
if (!content) { showCardError(card, 'Content is required'); return; }
|
||||
if (!scheduledAt) { showCardError(card, 'Scheduled time is required'); return; }
|
||||
if (!targets.length) { showCardError(card, 'Select at least one platform target'); return; }
|
||||
|
||||
clearTimeout(card._autosaveTimer);
|
||||
setSaveIndicator(card, 'saving');
|
||||
|
||||
const body = { action: isNew ? 'create' : 'update', content, scheduled_at: scheduledAt, image_path: imagePath, targets };
|
||||
if (!isNew) body.id = post.id;
|
||||
|
||||
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const d = await r.json();
|
||||
if (d.error) { showSocialError(d.error); return; }
|
||||
resetForm();
|
||||
loadPosts();
|
||||
});
|
||||
if (d.error) { setSaveIndicator(card, 'error'); showCardError(card, d.error); return; }
|
||||
|
||||
// ── Edit ─────────────────────────────────────────────────────────────────────
|
||||
function startEdit(post) {
|
||||
document.getElementById('socialEditId').value = post.id;
|
||||
document.getElementById('socialContent').value = post.content;
|
||||
document.getElementById('socialScheduledAt').value = post.scheduled_at.replace(' ', 'T').substring(0, 16);
|
||||
document.getElementById('socialFormTitle').textContent = 'Edit post';
|
||||
document.getElementById('socialCancelEditBtn').classList.remove('d-none');
|
||||
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-pencil me-1"></i>Update';
|
||||
document.getElementById('socialContent').dispatchEvent(new Event('input'));
|
||||
|
||||
if (post.image_url) {
|
||||
document.getElementById('socialImagePath').value = post.image_path || '';
|
||||
document.getElementById('socialImageThumb').src = post.image_url;
|
||||
document.getElementById('socialImagePreview').classList.remove('d-none');
|
||||
document.getElementById('socialRemoveImageBtn').classList.remove('d-none');
|
||||
toggleImageCols(true);
|
||||
} else {
|
||||
toggleImageCols(false);
|
||||
if (isNew) {
|
||||
// reset new-post card
|
||||
card.innerHTML = buildEditPane(null);
|
||||
initEditCard(card, null);
|
||||
}
|
||||
|
||||
renderTargets(post.targets || []);
|
||||
document.getElementById('socialFormCard').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
hideSocialError();
|
||||
loadPosts();
|
||||
}
|
||||
|
||||
document.getElementById('socialCancelEditBtn').addEventListener('click', resetForm);
|
||||
|
||||
// ── Duplicate ─────────────────────────────────────────────────────────────────
|
||||
async function duplicatePost(id) {
|
||||
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'duplicate', id }) });
|
||||
const d = await r.json();
|
||||
if (d.error) { showSocialError(d.error); return; }
|
||||
if (d.error) { return; }
|
||||
const newCard = document.querySelector('.sc-new-card');
|
||||
if (!newCard) return;
|
||||
newCard.innerHTML = buildEditPane({ ...d.post, scheduled_at: '', targets: d.targets || [] });
|
||||
newCard.querySelector('.sc-dt').value = '';
|
||||
initEditCard(newCard, null);
|
||||
newCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
document.getElementById('socialEditId').value = '';
|
||||
document.getElementById('socialContent').value = d.post.content;
|
||||
document.getElementById('socialScheduledAt').value = '';
|
||||
document.getElementById('socialFormTitle').textContent = 'Schedule a post';
|
||||
document.getElementById('socialCancelEditBtn').classList.add('d-none');
|
||||
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-calendar-plus me-1"></i>Schedule';
|
||||
document.getElementById('socialContent').dispatchEvent(new Event('input'));
|
||||
// ── Grid render ───────────────────────────────────────────────────────────────
|
||||
function renderGrid(posts) {
|
||||
const grid = document.getElementById('socialPostsGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (d.post.image_url) {
|
||||
document.getElementById('socialImagePath').value = d.post.image_path || '';
|
||||
document.getElementById('socialImageThumb').src = d.post.image_url;
|
||||
document.getElementById('socialImagePreview').classList.remove('d-none');
|
||||
document.getElementById('socialRemoveImageBtn').classList.remove('d-none');
|
||||
toggleImageCols(true);
|
||||
// "new post" card — always first
|
||||
const newCol = document.createElement('div');
|
||||
newCol.className = 'col-xl-4 col-md-6 col-12';
|
||||
newCol.innerHTML = `<div class="card sc-card sc-new-card h-100">${buildEditPane(null)}</div>`;
|
||||
grid.appendChild(newCol);
|
||||
initEditCard(newCol.querySelector('.sc-card'), null);
|
||||
|
||||
if (!posts.length) {
|
||||
const emptyCol = document.createElement('div');
|
||||
emptyCol.className = 'col-12 d-flex align-items-center text-muted small py-4 ps-3';
|
||||
emptyCol.innerHTML = `<i class="bi bi-calendar2-x me-2" style="font-size:1.5rem;opacity:.4"></i>${_filter === 'pending' ? 'No pending posts.' : 'No posts yet.'}`;
|
||||
grid.appendChild(emptyCol);
|
||||
} else {
|
||||
document.getElementById('socialImagePath').value = '';
|
||||
document.getElementById('socialImagePreview').classList.add('d-none');
|
||||
document.getElementById('socialRemoveImageBtn').classList.add('d-none');
|
||||
toggleImageCols(false);
|
||||
posts.forEach(post => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-xl-4 col-md-6 col-12';
|
||||
col.innerHTML = `<div class="card sc-card sc-post-card status-${esc(post.status)} h-100">${buildViewPane(post)}</div>`;
|
||||
grid.appendChild(col);
|
||||
initViewCard(col.querySelector('.sc-card'), post);
|
||||
});
|
||||
}
|
||||
|
||||
renderTargets(d.targets || []);
|
||||
document.getElementById('socialFormCard').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
hideSocialError();
|
||||
constrainImages();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
document.getElementById('socialEditId').value = '';
|
||||
document.getElementById('socialContent').value = '';
|
||||
document.getElementById('socialScheduledAt').value = defaultScheduleTime();
|
||||
document.getElementById('socialImagePath').value = '';
|
||||
document.getElementById('socialImageThumb').src = '';
|
||||
document.getElementById('socialImagePreview').classList.add('d-none');
|
||||
document.getElementById('socialRemoveImageBtn').classList.add('d-none');
|
||||
document.getElementById('socialFormTitle').textContent = 'Schedule a post';
|
||||
document.getElementById('socialCancelEditBtn').classList.add('d-none');
|
||||
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-calendar-plus me-1"></i>Schedule';
|
||||
document.getElementById('socialContent').dispatchEvent(new Event('input'));
|
||||
toggleImageCols(false);
|
||||
renderTargets(null);
|
||||
hideSocialError();
|
||||
function constrainImages() {
|
||||
document.querySelectorAll('.sc-img-wrap').forEach(wrap => {
|
||||
const w = wrap.clientWidth || wrap.parentElement?.clientWidth;
|
||||
if (w) wrap.style.maxHeight = w + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Post list ─────────────────────────────────────────────────────────────────
|
||||
// ── Load posts ────────────────────────────────────────────────────────────────
|
||||
async function loadPosts() {
|
||||
const r = await fetch('/api/social?status=' + _filter);
|
||||
const d = await r.json();
|
||||
const el = document.getElementById('socialPostsList');
|
||||
if (d.error) { el.innerHTML = `<div class="alert alert-danger small">${esc(d.error)}</div>`; return; }
|
||||
if (!d.posts.length) {
|
||||
el.innerHTML = `<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-calendar2-x" style="font-size:2rem;opacity:.4"></i>
|
||||
<div class="mt-2 small">${_filter === 'pending' ? 'No pending posts.' : 'No posts yet.'}</div>
|
||||
</div>`;
|
||||
const grid = document.getElementById('socialPostsGrid');
|
||||
if (d.error) {
|
||||
grid.innerHTML = `<div class="col-12"><div class="alert alert-danger small">${esc(d.error)}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' };
|
||||
const statusBadge = s => {
|
||||
const map = { pending: ['bg-warning text-dark', 'clock'], sent: ['bg-success', 'check-circle'], failed: ['bg-danger', 'x-circle'], done: ['bg-success', 'check-circle-fill'], partial: ['bg-warning text-dark', 'exclamation-circle'], processing: ['bg-info text-dark', 'arrow-repeat'] };
|
||||
const [cls, icon] = map[s] || ['bg-secondary', 'dot'];
|
||||
return `<span class="badge ${cls} fw-normal"><i class="bi bi-${icon} me-1"></i>${esc(s)}</span>`;
|
||||
};
|
||||
|
||||
el.innerHTML = d.posts.map(post => {
|
||||
const preview = post.content.length > 200 ? post.content.substring(0, 200) + '…' : post.content;
|
||||
const canRetry = post.targets?.some(t => t.status === 'failed');
|
||||
const imgHtml = post.image_url
|
||||
? `<div class="flex-shrink-0 ms-2"><img src="${esc(post.image_url)}" style="width:60px;height:60px;object-fit:cover;border-radius:4px;border:1px solid var(--hm-border)"></div>`
|
||||
: '';
|
||||
|
||||
const targBadges = (post.targets || []).map(t => {
|
||||
const statusCls = { sent: 'bg-success', failed: 'bg-danger', pending: 'bg-warning text-dark' };
|
||||
return `<span class="badge border social-platform-${esc(t.platform)} fw-normal d-inline-flex align-items-center gap-1">
|
||||
<i class="bi ${platformIcon[t.platform] || 'bi-dot'}"></i>${esc(t.target_key)}
|
||||
<span class="badge ${statusCls[t.status] || 'bg-secondary'} ms-1" style="font-size:.65em">${esc(t.status)}</span>
|
||||
${t.error ? `<i class="bi bi-exclamation-circle text-danger ms-1" title="${esc(t.error)}"></i>` : ''}
|
||||
</span>`;
|
||||
}).join(' ');
|
||||
|
||||
return `<div class="card mb-2 social-post-card status-${esc(post.status)}" data-id="${post.id}">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
${statusBadge(post.status)}
|
||||
<span class="ms-auto small text-muted">
|
||||
<i class="bi bi-clock me-1"></i>${esc(post.scheduled_at.substring(0, 16).replace('T',' '))}
|
||||
<span class="text-muted opacity-75 ms-1">(${relTime(post.scheduled_at)})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-start mb-2">
|
||||
<div class="flex-grow-1 small" style="white-space:pre-wrap;word-break:break-word">${esc(preview)}</div>
|
||||
${imgHtml}
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">${targBadges}</div>
|
||||
<div class="d-flex gap-1 justify-content-end">
|
||||
${canRetry ? `<button class="btn btn-xs btn-outline-warning retry-post-btn" data-id="${post.id}"><i class="bi bi-arrow-repeat me-1"></i>Retry</button>` : ''}
|
||||
<button class="btn btn-xs btn-outline-secondary edit-post-btn" data-id="${post.id}"><i class="bi bi-pencil me-1"></i>Edit</button>
|
||||
<button class="btn btn-xs btn-outline-secondary dup-post-btn" data-id="${post.id}"><i class="bi bi-copy me-1"></i>Dup</button>
|
||||
<button class="btn btn-xs btn-outline-danger del-post-btn" data-id="${post.id}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
el._posts = d.posts;
|
||||
|
||||
el.querySelectorAll('.edit-post-btn').forEach(b => b.addEventListener('click', () => {
|
||||
const post = el._posts.find(p => p.id == b.dataset.id);
|
||||
if (post) startEdit(post);
|
||||
}));
|
||||
|
||||
el.querySelectorAll('.dup-post-btn').forEach(b => b.addEventListener('click', () => duplicatePost(+b.dataset.id)));
|
||||
|
||||
el.querySelectorAll('.del-post-btn').forEach(b => b.addEventListener('click', async () => {
|
||||
if (!b.dataset.confirm) {
|
||||
b.dataset.confirm = '1';
|
||||
b.textContent = 'Sure?';
|
||||
b.classList.replace('btn-outline-danger', 'btn-danger');
|
||||
setTimeout(() => {
|
||||
if (b.dataset.confirm) {
|
||||
delete b.dataset.confirm;
|
||||
b.textContent = 'Delete';
|
||||
b.classList.replace('btn-danger', 'btn-outline-danger');
|
||||
}
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
delete b.dataset.confirm;
|
||||
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', id: +b.dataset.id }) });
|
||||
const d = await r.json();
|
||||
if (d.ok) loadPosts(); else showSocialError(d.error);
|
||||
}));
|
||||
|
||||
el.querySelectorAll('.retry-post-btn').forEach(b => b.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'retry', id: +b.dataset.id }) });
|
||||
const d = await r.json();
|
||||
if (d.ok) loadPosts(); else showSocialError(d.error);
|
||||
}));
|
||||
renderGrid(d.posts);
|
||||
}
|
||||
|
||||
// ── Filters ───────────────────────────────────────────────────────────────────
|
||||
|
|
@ -4187,20 +4308,19 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||
});
|
||||
document.getElementById('socialRefreshBtn').addEventListener('click', loadPosts);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function showSocialError(msg) {
|
||||
const el = document.getElementById('socialFormError');
|
||||
// ── Per-card error helpers ────────────────────────────────────────────────────
|
||||
function showCardError(card, msg) {
|
||||
const el = card.querySelector('.sc-error');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove('d-none');
|
||||
}
|
||||
function hideSocialError() {
|
||||
document.getElementById('socialFormError').classList.add('d-none');
|
||||
function hideCardError(card) {
|
||||
card.querySelector('.sc-error')?.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Init
|
||||
document.getElementById('socialScheduledAt').value = defaultScheduleTime();
|
||||
loadTargets();
|
||||
loadPosts();
|
||||
loadTargets().then(() => loadPosts());
|
||||
})();
|
||||
|
||||
// ── Settings: social eligible projects ──────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue