From 1a366bef634baad615d1ca119b5c43200057e4e2 Mon Sep 17 00:00:00 2001 From: Bashy Date: Thu, 4 Jun 2026 19:26:55 +0300 Subject: [PATCH] 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 --- views/social.php | 29 +++++- web/assets/css/app.css | 20 +++++ web/assets/js/app.js | 200 +++++++++++++++++++++++++++-------------- 3 files changed, 183 insertions(+), 66 deletions(-) diff --git a/views/social.php b/views/social.php index b8d4e96..b61f0e9 100644 --- a/views/social.php +++ b/views/social.php @@ -20,9 +20,36 @@ include ROOT . '/views/_header.php';
- +
+ + +
+
+ 0 / 500 +
diff --git a/web/assets/css/app.css b/web/assets/css/app.css index 1e077c0..74ac723 100644 --- a/web/assets/css/app.css +++ b/web/assets/css/app.css @@ -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; } + diff --git a/web/assets/js/app.js b/web/assets/js/app.js index 64809a7..c6ccc70 100644 --- a/web/assets/js/app.js +++ b/web/assets/js/app.js @@ -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,'&').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 => `${e}`).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 = '
No eligible projects configured. Go to Settings to enable projects.
'; 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 `
@@ -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 = '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 = '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 = '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 = `
${esc(d.error)}
`; return; } - if (!d.posts.length) { el.innerHTML = '
No posts yet.
'; return; } + if (!d.posts.length) { + el.innerHTML = `
+ +
${_filter === 'pending' ? 'No pending posts.' : 'No posts yet.'}
+
`; + 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 `${esc(s)}`; - }; - 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 `${esc(s)}`; }; 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 => - ` - ${esc(t.target_key)} - ${statusBadge(t.status)} - ${t.error ? `` : ''} - `).join(' '); - const imgHtml = post.image_url - ? `
` + 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 + ? `
` : ''; - const canRetry = post.targets?.some(t => t.status === 'failed'); - return `