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:
Bashy 2026-06-04 19:26:55 +03:00
parent 8ab9b7bbe2
commit 1a366bef63
3 changed files with 183 additions and 66 deletions

View file

@ -447,3 +447,23 @@ pre { color: #e6edf3; }
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; }

View file

@ -3825,12 +3825,63 @@ window.addEventListener('DOMContentLoaded', () => {
const page = document.getElementById('socialPage');
if (!page) return;
let _targets = [];
let _filter = 'pending';
let _hasImage = false;
let _targets = [];
let _filter = 'pending';
let _hasImage = false;
const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
// 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 ──────────────────────────────────────────────────────────────────
async function loadTargets() {
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>';
return;
}
const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' };
const platformLabel = { discord: 'Discord', mastodon: 'Mastodon', linkedin: 'LinkedIn' };
const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' };
const platformLabel = { discord: 'Discord', mastodon: 'Mastodon', linkedin: 'LinkedIn' };
el.innerHTML = _targets.map(group => {
const platforms = ['discord', 'mastodon', 'linkedin'].filter(p => group.targets[p]?.length);
@ -3859,8 +3910,8 @@ window.addEventListener('DOMContentLoaded', () => {
${group.targets[platform].map(t => {
const cbId = `st_${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 imgChecked = prefill?.some(x => x.project_id == group.project_id && x.platform === platform && x.target_key === t.key && x.include_image) ? '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' : '';
return `<div class="d-flex align-items-center gap-2 mb-1">
<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}>
@ -3936,8 +3987,8 @@ window.addEventListener('DOMContentLoaded', () => {
const targets = collectTargets();
hideSocialError();
if (!content) { showSocialError('Content is required'); return; }
if (!scheduledAt) { showSocialError('Scheduled time is required'); return; }
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 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('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 || '';
@ -3980,14 +4032,15 @@ window.addEventListener('DOMContentLoaded', () => {
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) { alert(d.error); return; }
if (d.error) { showSocialError(d.error); return; }
document.getElementById('socialEditId').value = '';
document.getElementById('socialContent').value = d.post.content;
document.getElementById('socialScheduledAt').value = '';
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'));
if (d.post.image_url) {
document.getElementById('socialImagePath').value = d.post.image_path || '';
@ -4008,16 +4061,17 @@ window.addEventListener('DOMContentLoaded', () => {
}
function resetForm() {
document.getElementById('socialEditId').value = '';
document.getElementById('socialContent').value = '';
document.getElementById('socialScheduledAt').value = '';
document.getElementById('socialImagePath').value = '';
document.getElementById('socialImageThumb').src = '';
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();
@ -4029,75 +4083,94 @@ window.addEventListener('DOMContentLoaded', () => {
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-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 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' };
return `<span class="badge ${cls[s] || 'bg-secondary'} fw-normal">${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');
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 > 160 ? post.content.substring(0, 160) + '…' : post.content;
const targBadges = (post.targets || []).map(t =>
`<span class="badge bg-secondary fw-normal d-inline-flex align-items-center gap-1">
<i class="bi ${platformIcon[t.platform] || 'bi-dot'}"></i>${esc(t.target_key)}
${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 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 canRetry = post.targets?.some(t => t.status === 'failed');
return `<div class="card mb-2 social-post-card" data-id="${post.id}">
<div class="card-body py-2">
<div class="d-flex gap-3 align-items-start">
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 class="flex-grow-1 min-width-0">
<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 align-items-center gap-3 flex-wrap">
<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>
${statusBadge(post.status)}
<div class="ms-auto d-flex gap-1">
${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 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('');
// Store post data for edit
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 (!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 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 () => {
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 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');
}
// Default datetime to now + 1 hour
const dt = new Date(Date.now() + 3600000);
dt.setSeconds(0, 0);
document.getElementById('socialScheduledAt').value = dt.toISOString().slice(0, 16);
// Init
document.getElementById('socialScheduledAt').value = defaultScheduleTime();
loadTargets();
loadPosts();
})();