Overhaul social scheduler UI
- Emoji picker with 6 categories (~100 emojis), inserts at cursor - Character counter (0/500) with amber/red thresholds - Default schedule time is now +5 min in browser local time (fixes UTC offset bug) - Relative time display uses local time (removes incorrect +Z suffix) - Post cards redesigned: color-coded left border by status, platform badges in Discord/Mastodon/LinkedIn brand colors, status icons - Delete is two-click confirm (no confirm() dialog) - Duplicate errors use inline error area (no alert()) - Nicer empty state with icon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8ab9b7bbe2
commit
1a366bef63
3 changed files with 183 additions and 66 deletions
|
|
@ -20,9 +20,36 @@ include ROOT . '/views/_header.php';
|
||||||
<input type="hidden" id="socialEditId">
|
<input type="hidden" id="socialEditId">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label small">Content</label>
|
<div class="d-flex align-items-center mb-1">
|
||||||
|
<label class="form-label small mb-0">Content</label>
|
||||||
|
<div class="ms-auto dropdown">
|
||||||
|
<button type="button" class="btn btn-xs btn-outline-secondary" id="socialEmojiBtn"
|
||||||
|
data-bs-toggle="dropdown" data-bs-auto-close="outside" title="Insert emoji">
|
||||||
|
😊
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu p-2 social-emoji-picker" aria-labelledby="socialEmojiBtn">
|
||||||
|
<div class="social-emoji-grid">
|
||||||
|
<div class="social-emoji-cat">Smileys</div>
|
||||||
|
<div>😀 😂 🥰 😍 🤩 😎 😭 😤 🤔 😴 🥳 🤗 😅 🫡 😇 🙃 😘 😣 😝 🥺 👀</div>
|
||||||
|
<div class="social-emoji-cat mt-1">Gestures</div>
|
||||||
|
<div>👍 👎 👏 🙌 🤝 ✌️ 👌 🤞 🫶 💪 🎉 🔥 ✨ ⭐ 💯 🙏 👋 🤷♀️</div>
|
||||||
|
<div class="social-emoji-cat mt-1">Hearts</div>
|
||||||
|
<div>❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 💕 💞 💓 💗 💖 🫀 ♥️</div>
|
||||||
|
<div class="social-emoji-cat mt-1">Nature</div>
|
||||||
|
<div>🌟 🌈 🌸 🌺 🌻 🌙 ☀️ 🌊 🌿 🍀 🦋 🌍 🌱 🎄 🌴 🌵</div>
|
||||||
|
<div class="social-emoji-cat mt-1">Objects</div>
|
||||||
|
<div>💻 📱 📸 🎵 🎮 🎨 📚 🏆 🔑 💡 🔔 📢 🎯 📊 🚀 🎁</div>
|
||||||
|
<div class="social-emoji-cat mt-1">Symbols</div>
|
||||||
|
<div>✅ ❌ ⚡ 🔗 💬 📌 🔍 💰 ⏰ 📅 🗓️ ➡️ ⬆️ 🔄 💤 ❓</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<textarea id="socialContent" class="form-control form-control-sm font-monospace"
|
<textarea id="socialContent" class="form-control form-control-sm font-monospace"
|
||||||
rows="7" placeholder="What's on your mind?"></textarea>
|
rows="7" placeholder="What's on your mind?"></textarea>
|
||||||
|
<div class="d-flex justify-content-end mt-1">
|
||||||
|
<span id="socialCharCount" class="small text-muted">0 / 500</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|
|
||||||
|
|
@ -447,3 +447,23 @@ pre { color: #e6edf3; }
|
||||||
color: rgba(255,255,255,.7); font-size: .85rem;
|
color: rgba(255,255,255,.7); font-size: .85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Social Scheduler */
|
||||||
|
.social-emoji-picker { min-width: 260px; max-height: 280px; overflow-y: auto; background: var(--hm-surface); border-color: var(--hm-border); }
|
||||||
|
.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; }
|
||||||
|
.social-emoji-grid span.emoji-btn:hover { background: rgba(255,255,255,.1); }
|
||||||
|
|
||||||
|
.social-platform-discord { background: rgba(88,101,242,.25); color: #7289da; border-color: rgba(88,101,242,.4); }
|
||||||
|
.social-platform-mastodon { background: rgba(99,100,255,.25); color: #a29cf4; border-color: rgba(99,100,255,.4); }
|
||||||
|
.social-platform-linkedin { background: rgba(10,102,194,.25); color: #5ba3d9; border-color: rgba(10,102,194,.4); }
|
||||||
|
|
||||||
|
.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-char-warn { color: #e3b341 !important; }
|
||||||
|
.social-char-over { color: #f85149 !important; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3825,12 +3825,63 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
const page = document.getElementById('socialPage');
|
const page = document.getElementById('socialPage');
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
|
|
||||||
let _targets = [];
|
let _targets = [];
|
||||||
let _filter = 'pending';
|
let _filter = 'pending';
|
||||||
let _hasImage = false;
|
let _hasImage = false;
|
||||||
|
|
||||||
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
const suf = diff > 0 ? '' : ' ago';
|
||||||
|
const pre = diff > 0 ? 'in ' : '';
|
||||||
|
if (abs < 60) return 'just now';
|
||||||
|
if (abs < 3600) return pre + Math.round(abs / 60) + ' min' + suf;
|
||||||
|
if (abs < 86400) return pre + Math.round(abs / 3600) + ' h' + suf;
|
||||||
|
return pre + Math.round(abs / 86400) + ' d' + suf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap each space-separated emoji in a clickable span
|
||||||
|
document.querySelectorAll('.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');
|
||||||
|
});
|
||||||
|
|
||||||
// ── Targets ──────────────────────────────────────────────────────────────────
|
// ── Targets ──────────────────────────────────────────────────────────────────
|
||||||
async function loadTargets() {
|
async function loadTargets() {
|
||||||
const r = await fetch('/api/social?action=targets');
|
const r = await fetch('/api/social?action=targets');
|
||||||
|
|
@ -3845,8 +3896,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
el.innerHTML = '<div class="text-muted small">No eligible projects configured. <a href="/settings">Go to Settings</a> to enable projects.</div>';
|
el.innerHTML = '<div class="text-muted small">No eligible projects configured. <a href="/settings">Go to Settings</a> to enable projects.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' };
|
const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' };
|
||||||
const platformLabel = { discord: 'Discord', mastodon: 'Mastodon', linkedin: 'LinkedIn' };
|
const platformLabel = { discord: 'Discord', mastodon: 'Mastodon', linkedin: 'LinkedIn' };
|
||||||
|
|
||||||
el.innerHTML = _targets.map(group => {
|
el.innerHTML = _targets.map(group => {
|
||||||
const platforms = ['discord', 'mastodon', 'linkedin'].filter(p => group.targets[p]?.length);
|
const platforms = ['discord', 'mastodon', 'linkedin'].filter(p => group.targets[p]?.length);
|
||||||
|
|
@ -3859,8 +3910,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
${group.targets[platform].map(t => {
|
${group.targets[platform].map(t => {
|
||||||
const cbId = `st_${group.project_id}_${platform}_${t.key}`;
|
const cbId = `st_${group.project_id}_${platform}_${t.key}`;
|
||||||
const imgId = `si_${group.project_id}_${platform}_${t.key}`;
|
const imgId = `si_${group.project_id}_${platform}_${t.key}`;
|
||||||
const checked = prefill?.some(x => x.project_id == group.project_id && x.platform === platform && x.target_key === t.key) ? 'checked' : '';
|
const checked = prefill?.some(x => x.project_id == group.project_id && x.platform === platform && x.target_key === t.key) ? 'checked' : '';
|
||||||
const imgChecked = prefill?.some(x => x.project_id == group.project_id && x.platform === platform && x.target_key === t.key && x.include_image) ? 'checked' : '';
|
const imgChecked = prefill?.some(x => x.project_id == group.project_id && x.platform === platform && x.target_key === t.key && x.include_image) ? 'checked' : '';
|
||||||
return `<div class="d-flex align-items-center gap-2 mb-1">
|
return `<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
<input class="form-check-input social-target-cb" type="checkbox" id="${cbId}"
|
<input class="form-check-input social-target-cb" type="checkbox" id="${cbId}"
|
||||||
data-pid="${group.project_id}" data-platform="${platform}" data-key="${esc(t.key)}" ${checked}>
|
data-pid="${group.project_id}" data-platform="${platform}" data-key="${esc(t.key)}" ${checked}>
|
||||||
|
|
@ -3936,8 +3987,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
const targets = collectTargets();
|
const targets = collectTargets();
|
||||||
|
|
||||||
hideSocialError();
|
hideSocialError();
|
||||||
if (!content) { showSocialError('Content is required'); return; }
|
if (!content) { showSocialError('Content is required'); return; }
|
||||||
if (!scheduledAt) { showSocialError('Scheduled time is required'); return; }
|
if (!scheduledAt) { showSocialError('Scheduled time is required'); return; }
|
||||||
if (!targets.length) { showSocialError('Select at least one platform target'); return; }
|
if (!targets.length) { showSocialError('Select at least one platform target'); return; }
|
||||||
|
|
||||||
const body = { action: editId ? 'update' : 'create', content, scheduled_at: scheduledAt, image_path: imagePath, targets };
|
const body = { action: editId ? 'update' : 'create', content, scheduled_at: scheduledAt, image_path: imagePath, targets };
|
||||||
|
|
@ -3958,6 +4009,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
document.getElementById('socialFormTitle').textContent = 'Edit post';
|
document.getElementById('socialFormTitle').textContent = 'Edit post';
|
||||||
document.getElementById('socialCancelEditBtn').classList.remove('d-none');
|
document.getElementById('socialCancelEditBtn').classList.remove('d-none');
|
||||||
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-pencil me-1"></i>Update';
|
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-pencil me-1"></i>Update';
|
||||||
|
document.getElementById('socialContent').dispatchEvent(new Event('input'));
|
||||||
|
|
||||||
if (post.image_url) {
|
if (post.image_url) {
|
||||||
document.getElementById('socialImagePath').value = post.image_path || '';
|
document.getElementById('socialImagePath').value = post.image_path || '';
|
||||||
|
|
@ -3980,14 +4032,15 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
async function duplicatePost(id) {
|
async function duplicatePost(id) {
|
||||||
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'duplicate', 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();
|
const d = await r.json();
|
||||||
if (d.error) { alert(d.error); return; }
|
if (d.error) { showSocialError(d.error); return; }
|
||||||
|
|
||||||
document.getElementById('socialEditId').value = '';
|
document.getElementById('socialEditId').value = '';
|
||||||
document.getElementById('socialContent').value = d.post.content;
|
document.getElementById('socialContent').value = d.post.content;
|
||||||
document.getElementById('socialScheduledAt').value = '';
|
document.getElementById('socialScheduledAt').value = '';
|
||||||
document.getElementById('socialFormTitle').textContent = 'Schedule a post';
|
document.getElementById('socialFormTitle').textContent = 'Schedule a post';
|
||||||
document.getElementById('socialCancelEditBtn').classList.add('d-none');
|
document.getElementById('socialCancelEditBtn').classList.add('d-none');
|
||||||
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-calendar-plus me-1"></i>Schedule';
|
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-calendar-plus me-1"></i>Schedule';
|
||||||
|
document.getElementById('socialContent').dispatchEvent(new Event('input'));
|
||||||
|
|
||||||
if (d.post.image_url) {
|
if (d.post.image_url) {
|
||||||
document.getElementById('socialImagePath').value = d.post.image_path || '';
|
document.getElementById('socialImagePath').value = d.post.image_path || '';
|
||||||
|
|
@ -4008,16 +4061,17 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
document.getElementById('socialEditId').value = '';
|
document.getElementById('socialEditId').value = '';
|
||||||
document.getElementById('socialContent').value = '';
|
document.getElementById('socialContent').value = '';
|
||||||
document.getElementById('socialScheduledAt').value = '';
|
document.getElementById('socialScheduledAt').value = defaultScheduleTime();
|
||||||
document.getElementById('socialImagePath').value = '';
|
document.getElementById('socialImagePath').value = '';
|
||||||
document.getElementById('socialImageThumb').src = '';
|
document.getElementById('socialImageThumb').src = '';
|
||||||
document.getElementById('socialImagePreview').classList.add('d-none');
|
document.getElementById('socialImagePreview').classList.add('d-none');
|
||||||
document.getElementById('socialRemoveImageBtn').classList.add('d-none');
|
document.getElementById('socialRemoveImageBtn').classList.add('d-none');
|
||||||
document.getElementById('socialFormTitle').textContent = 'Schedule a post';
|
document.getElementById('socialFormTitle').textContent = 'Schedule a post';
|
||||||
document.getElementById('socialCancelEditBtn').classList.add('d-none');
|
document.getElementById('socialCancelEditBtn').classList.add('d-none');
|
||||||
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-calendar-plus me-1"></i>Schedule';
|
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-calendar-plus me-1"></i>Schedule';
|
||||||
|
document.getElementById('socialContent').dispatchEvent(new Event('input'));
|
||||||
toggleImageCols(false);
|
toggleImageCols(false);
|
||||||
renderTargets(null);
|
renderTargets(null);
|
||||||
hideSocialError();
|
hideSocialError();
|
||||||
|
|
@ -4029,75 +4083,94 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
const el = document.getElementById('socialPostsList');
|
const el = document.getElementById('socialPostsList');
|
||||||
if (d.error) { el.innerHTML = `<div class="alert alert-danger small">${esc(d.error)}</div>`; return; }
|
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-muted small">No posts yet.</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>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' };
|
||||||
const statusBadge = s => {
|
const statusBadge = s => {
|
||||||
const cls = { pending: 'bg-warning text-dark', sent: 'bg-success', failed: 'bg-danger', done: 'bg-success', partial: 'bg-warning text-dark', processing: 'bg-info text-dark' };
|
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'] };
|
||||||
return `<span class="badge ${cls[s] || 'bg-secondary'} fw-normal">${esc(s)}</span>`;
|
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>`;
|
||||||
const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' };
|
|
||||||
const relTime = ts => {
|
|
||||||
const diff = (new Date(ts.replace(' ', 'T') + 'Z') - Date.now()) / 1000;
|
|
||||||
if (Math.abs(diff) < 60) return 'just now';
|
|
||||||
if (Math.abs(diff) < 3600) return Math.round(Math.abs(diff)/60) + (diff>0?' min':' min ago');
|
|
||||||
if (Math.abs(diff) < 86400) return Math.round(Math.abs(diff)/3600) + (diff>0?' h':' h ago');
|
|
||||||
return Math.round(Math.abs(diff)/86400) + (diff>0?' d':' d ago');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
el.innerHTML = d.posts.map(post => {
|
el.innerHTML = d.posts.map(post => {
|
||||||
const preview = post.content.length > 160 ? post.content.substring(0, 160) + '…' : post.content;
|
const preview = post.content.length > 200 ? post.content.substring(0, 200) + '…' : post.content;
|
||||||
const targBadges = (post.targets || []).map(t =>
|
const canRetry = post.targets?.some(t => t.status === 'failed');
|
||||||
`<span class="badge bg-secondary fw-normal d-inline-flex align-items-center gap-1">
|
const imgHtml = post.image_url
|
||||||
<i class="bi ${platformIcon[t.platform] || 'bi-dot'}"></i>${esc(t.target_key)}
|
? `<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>`
|
||||||
${statusBadge(t.status)}
|
|
||||||
${t.error ? `<span title="${esc(t.error)}"><i class="bi bi-exclamation-circle text-danger"></i></span>` : ''}
|
|
||||||
</span>`).join(' ');
|
|
||||||
const imgHtml = post.image_url
|
|
||||||
? `<div class="flex-shrink-0"><img src="${esc(post.image_url)}" style="width:72px;height:72px;object-fit:cover;border-radius:4px;border:1px solid var(--hm-border)"></div>`
|
|
||||||
: '';
|
: '';
|
||||||
const canRetry = post.targets?.some(t => t.status === 'failed');
|
|
||||||
|
|
||||||
return `<div class="card mb-2 social-post-card" data-id="${post.id}">
|
const targBadges = (post.targets || []).map(t => {
|
||||||
<div class="card-body py-2">
|
const statusCls = { sent: 'bg-success', failed: 'bg-danger', pending: 'bg-warning text-dark' };
|
||||||
<div class="d-flex gap-3 align-items-start">
|
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}
|
${imgHtml}
|
||||||
<div class="flex-grow-1 min-width-0">
|
</div>
|
||||||
<div class="small mb-2" style="white-space:pre-wrap">${esc(preview)}</div>
|
<div class="d-flex flex-wrap gap-1 mb-2">${targBadges}</div>
|
||||||
<div class="d-flex flex-wrap gap-1 mb-2">${targBadges}</div>
|
<div class="d-flex gap-1 justify-content-end">
|
||||||
<div class="d-flex align-items-center gap-3 flex-wrap">
|
${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>` : ''}
|
||||||
<span class="small text-muted"><i class="bi bi-clock me-1"></i>${esc(post.scheduled_at)} <span class="text-muted">(${relTime(post.scheduled_at)})</span></span>
|
<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>
|
||||||
${statusBadge(post.status)}
|
<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>
|
||||||
<div class="ms-auto d-flex gap-1">
|
<button class="btn btn-xs btn-outline-danger del-post-btn" data-id="${post.id}">Delete</button>
|
||||||
${canRetry ? `<button class="btn btn-xs btn-outline-warning retry-post-btn" data-id="${post.id}">Retry</button>` : ''}
|
|
||||||
<button class="btn btn-xs btn-outline-secondary edit-post-btn" data-id="${post.id}">Edit</button>
|
|
||||||
<button class="btn btn-xs btn-outline-secondary dup-post-btn" data-id="${post.id}">Duplicate</button>
|
|
||||||
<button class="btn btn-xs btn-outline-danger del-post-btn" data-id="${post.id}">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Store post data for edit
|
|
||||||
el._posts = d.posts;
|
el._posts = d.posts;
|
||||||
|
|
||||||
el.querySelectorAll('.edit-post-btn').forEach(b => b.addEventListener('click', () => {
|
el.querySelectorAll('.edit-post-btn').forEach(b => b.addEventListener('click', () => {
|
||||||
const post = el._posts.find(p => p.id == b.dataset.id);
|
const post = el._posts.find(p => p.id == b.dataset.id);
|
||||||
if (post) startEdit(post);
|
if (post) startEdit(post);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
el.querySelectorAll('.dup-post-btn').forEach(b => b.addEventListener('click', () => duplicatePost(+b.dataset.id)));
|
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 () => {
|
el.querySelectorAll('.del-post-btn').forEach(b => b.addEventListener('click', async () => {
|
||||||
if (!confirm('Delete this post?')) return;
|
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 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();
|
const d = await r.json();
|
||||||
if (d.ok) loadPosts(); else alert(d.error);
|
if (d.ok) loadPosts(); else showSocialError(d.error);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
el.querySelectorAll('.retry-post-btn').forEach(b => b.addEventListener('click', async () => {
|
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 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();
|
const d = await r.json();
|
||||||
if (d.ok) loadPosts(); else alert(d.error);
|
if (d.ok) loadPosts(); else showSocialError(d.error);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4126,11 +4199,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
document.getElementById('socialFormError').classList.add('d-none');
|
document.getElementById('socialFormError').classList.add('d-none');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default datetime to now + 1 hour
|
// Init
|
||||||
const dt = new Date(Date.now() + 3600000);
|
document.getElementById('socialScheduledAt').value = defaultScheduleTime();
|
||||||
dt.setSeconds(0, 0);
|
|
||||||
document.getElementById('socialScheduledAt').value = dt.toISOString().slice(0, 16);
|
|
||||||
|
|
||||||
loadTargets();
|
loadTargets();
|
||||||
loadPosts();
|
loadPosts();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue