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';
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 `
-
-
+ const targBadges = (post.targets || []).map(t => {
+ const statusCls = { sent: 'bg-success', failed: 'bg-danger', pending: 'bg-warning text-dark' };
+ return `
+ ${esc(t.target_key)}
+ ${esc(t.status)}
+ ${t.error ? `` : ''}
+ `;
+ }).join(' ');
+
+ return `
+
+
+ ${statusBadge(post.status)}
+
+ ${esc(post.scheduled_at.substring(0, 16).replace('T',' '))}
+ (${relTime(post.scheduled_at)})
+
+
+
+
${esc(preview)}
${imgHtml}
-
-
${esc(preview)}
-
${targBadges}
-
-
${esc(post.scheduled_at)} (${relTime(post.scheduled_at)})
- ${statusBadge(post.status)}
-
- ${canRetry ? `` : ''}
-
-
-
-
-
-
+
+
${targBadges}
+
+ ${canRetry ? `` : ''}
+
+
+
`;
}).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();
})();