diff --git a/services/arbiter-3.0/src/routes/admin/index.js b/services/arbiter-3.0/src/routes/admin/index.js index 01ba936..30e81ab 100644 --- a/services/arbiter-3.0/src/routes/admin/index.js +++ b/services/arbiter-3.0/src/routes/admin/index.js @@ -16,6 +16,7 @@ const schedulerRouter = require('./scheduler'); const discordAuditRouter = require('./discord-audit'); const systemRouter = require('./system'); const socialRouter = require('./social'); +const socialCalendarRouter = require('./social-calendar'); const infrastructureRouter = require('./infrastructure'); const aboutRouter = require('./about'); const mcpLogsRouter = require('./mcp-logs'); @@ -123,6 +124,7 @@ router.use('/scheduler', schedulerRouter); router.use('/discord', discordAuditRouter); router.use('/system', systemRouter); router.use('/social', socialRouter); +router.use('/social-calendar', socialCalendarRouter); router.use('/infrastructure', infrastructureRouter); router.use('/about', aboutRouter); router.use('/mcp-logs', mcpLogsRouter); diff --git a/services/arbiter-3.0/src/routes/admin/social-calendar.js b/services/arbiter-3.0/src/routes/admin/social-calendar.js new file mode 100644 index 0000000..d41abbc --- /dev/null +++ b/services/arbiter-3.0/src/routes/admin/social-calendar.js @@ -0,0 +1,160 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../../database'); + +const PLATFORMS = ['tiktok','facebook','instagram','x','bluesky','youtube','twitch','reddit']; +const STATUSES = ['draft','ready','scheduled','published','skipped']; + +// Parse ISO date (YYYY-MM-DD) into Monday of that week (local time) +function weekStart(dateStr) { + const d = dateStr ? new Date(dateStr + 'T00:00:00') : new Date(); + const day = d.getDay(); // 0=Sun..6=Sat + const diff = day === 0 ? -6 : 1 - day; // shift to Monday + d.setDate(d.getDate() + diff); + d.setHours(0,0,0,0); + return d; +} + +function toISODate(d) { + return d.toISOString().slice(0,10); +} + +// Split comma/space/newline hashtag input into array, strip leading #, dedupe +function parseHashtags(raw) { + if (!raw) return []; + return [...new Set( + String(raw).split(/[\s,]+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean) + )]; +} + +// Shell route +router.get('/', (req, res) => { + const start = weekStart(req.query.week); + res.render('admin/social-calendar/index', { + title: 'Social Calendar', + weekStartISO: toISODate(start), + PLATFORMS, STATUSES + }); +}); + +// HTMX week view +router.get('/week', async (req, res) => { + try { + const start = weekStart(req.query.week); + const end = new Date(start); end.setDate(end.getDate() + 7); + const { rows: plans } = await db.query(` + SELECT id, scheduled_at, platforms, status, caption, hashtags, + media_notes, link, assigned_to, notes + FROM social_post_plans + WHERE scheduled_at >= $1 AND scheduled_at < $2 + ORDER BY scheduled_at ASC + `, [start, end]); + + // Group by ISO date of scheduled_at + const byDay = {}; + for (let i = 0; i < 7; i++) { + const d = new Date(start); d.setDate(d.getDate() + i); + byDay[toISODate(d)] = { date: d, plans: [] }; + } + for (const p of plans) { + const key = toISODate(new Date(p.scheduled_at)); + if (byDay[key]) byDay[key].plans.push(p); + } + + const prev = new Date(start); prev.setDate(prev.getDate() - 7); + const next = new Date(start); next.setDate(next.getDate() + 7); + + res.render('admin/social-calendar/_week', { + byDay, PLATFORMS, STATUSES, + weekStartISO: toISODate(start), + prevWeekISO: toISODate(prev), + nextWeekISO: toISODate(next), + thisWeekISO: toISODate(weekStart()), + layout: false + }); + } catch (err) { + console.error('Social calendar week error:', err); + res.status(500).send("
Error loading calendar.
"); + } +}); + +// Create plan +router.post('/', async (req, res) => { + const { scheduled_at, platforms, status, caption, hashtags, media_notes, link, assigned_to, notes } = req.body; + const adminUsername = req.user.username; + try { + const platformsArr = Array.isArray(platforms) ? platforms : (platforms ? [platforms] : []); + const cleanPlatforms = platformsArr.filter(p => PLATFORMS.includes(p)); + const cleanStatus = STATUSES.includes(status) ? status : 'draft'; + const tagArr = parseHashtags(hashtags); + await db.query(` + INSERT INTO social_post_plans + (scheduled_at, platforms, status, caption, hashtags, media_notes, link, assigned_to, notes, created_by) + VALUES ($1, $2::social_platform[], $3, $4, $5, $6, $7, $8, $9, $10) + `, [scheduled_at, cleanPlatforms, cleanStatus, caption || '', tagArr, + media_notes || null, link || null, assigned_to || null, notes || null, adminUsername]); + res.set('HX-Trigger', 'refresh-week').status(201).send(''); + } catch (err) { + console.error('Create plan error:', err); + res.status(500).send(`
${err.message}
`); + } +}); + +// Update plan +router.post('/:id/update', async (req, res) => { + const { id } = req.params; + const { scheduled_at, platforms, status, caption, hashtags, media_notes, link, assigned_to, notes } = req.body; + try { + const platformsArr = Array.isArray(platforms) ? platforms : (platforms ? [platforms] : []); + const cleanPlatforms = platformsArr.filter(p => PLATFORMS.includes(p)); + const cleanStatus = STATUSES.includes(status) ? status : 'draft'; + const tagArr = parseHashtags(hashtags); + await db.query(` + UPDATE social_post_plans + SET scheduled_at=$1, platforms=$2::social_platform[], status=$3, caption=$4, + hashtags=$5, media_notes=$6, link=$7, assigned_to=$8, notes=$9, updated_at=NOW() + WHERE id=$10 + `, [scheduled_at, cleanPlatforms, cleanStatus, caption || '', tagArr, + media_notes || null, link || null, assigned_to || null, notes || null, id]); + res.set('HX-Trigger', 'refresh-week').status(200).send(''); + } catch (err) { + console.error('Update plan error:', err); + res.status(500).send(`
${err.message}
`); + } +}); + +// Delete plan +router.post('/:id/delete', async (req, res) => { + const { id } = req.params; + try { + await db.query(`DELETE FROM social_post_plans WHERE id=$1`, [id]); + res.set('HX-Trigger', 'refresh-week').status(200).send(''); + } catch (err) { + console.error('Delete plan error:', err); + res.status(500).send(`
${err.message}
`); + } +}); + +// Edit form (returns modal body) +router.get('/:id/edit', async (req, res) => { + try { + const { rows } = await db.query(`SELECT * FROM social_post_plans WHERE id=$1`, [req.params.id]); + if (!rows[0]) return res.status(404).send('Not found'); + res.render('admin/social-calendar/_form', { + plan: rows[0], PLATFORMS, STATUSES, layout: false + }); + } catch (err) { + console.error('Edit form error:', err); + res.status(500).send('Error'); + } +}); + +// New form (blank modal body, optionally prefilled with date) +router.get('/new', (req, res) => { + const date = req.query.date || new Date().toISOString().slice(0,10); + const plan = { id: null, scheduled_at: `${date}T12:00`, platforms: [], status: 'draft', + caption: '', hashtags: [], media_notes: '', link: '', assigned_to: '', notes: '' }; + res.render('admin/social-calendar/_form', { plan, PLATFORMS, STATUSES, layout: false }); +}); + +module.exports = router; diff --git a/services/arbiter-3.0/src/views/admin/social-calendar/_form.ejs b/services/arbiter-3.0/src/views/admin/social-calendar/_form.ejs new file mode 100644 index 0000000..7cfdf3b --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/social-calendar/_form.ejs @@ -0,0 +1,88 @@ +<% const isNew = !plan.id; + const action = isNew ? '/admin/social-calendar' : `/admin/social-calendar/${plan.id}/update`; + const scheduledValue = typeof plan.scheduled_at === 'string' ? plan.scheduled_at : new Date(plan.scheduled_at).toISOString().slice(0,16); + const hashtagsValue = Array.isArray(plan.hashtags) ? plan.hashtags.join(' ') : (plan.hashtags || ''); %> + +
+
+

<%= isNew ? '➕ New Post Plan' : `✏️ Edit Post #${plan.id}` %>

+ +
+ +
+
+ + +
+ +
+ +
+ <% PLATFORMS.forEach(function(p) { %> + + <% }); %> +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ <% if (!isNew) { %> + + <% } else { %><% } %> +
+ + +
+
+
+
diff --git a/services/arbiter-3.0/src/views/admin/social-calendar/_week.ejs b/services/arbiter-3.0/src/views/admin/social-calendar/_week.ejs new file mode 100644 index 0000000..dfbb492 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/social-calendar/_week.ejs @@ -0,0 +1,54 @@ +<% const platformIcons = {tiktok:'🎵',facebook:'📘',instagram:'📷',x:'✖️',bluesky:'🦋',youtube:'▶️',twitch:'🎮',reddit:'🤖'}; %> +<% const statusColors = {draft:'bg-gray-200 text-gray-800',ready:'bg-blue-100 text-blue-800',scheduled:'bg-purple-100 text-purple-800',published:'bg-green-100 text-green-800',skipped:'bg-red-100 text-red-800'}; %> + +
+ +
+
Week of <%= new Date(weekStartISO + 'T00:00:00').toLocaleDateString(undefined, {month:'long', day:'numeric', year:'numeric'}) %>
+ +
+ +
+ +
+ <% Object.keys(byDay).forEach(function(iso) { %> + <% const day = byDay[iso]; %> + <% const dayName = day.date.toLocaleDateString(undefined, {weekday:'short'}); %> + <% const dayNum = day.date.getDate(); %> +
+
+
+
<%= dayName %>
+
<%= dayNum %>
+
+ +
+
+ <% if (day.plans.length === 0) { %> +
No posts
+ <% } %> + <% day.plans.forEach(function(p) { %> +
+
+
+ <% (p.platforms || []).forEach(function(pl) { %> + <%= platformIcons[pl] || pl %> + <% }); %> +
+ <%= new Date(p.scheduled_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}) %> +
+
<%= p.caption || '(no caption)' %>
+ <%= p.status %> +
+ <% }); %> +
+
+ <% }); %> +
diff --git a/services/arbiter-3.0/src/views/admin/social-calendar/index.ejs b/services/arbiter-3.0/src/views/admin/social-calendar/index.ejs new file mode 100644 index 0000000..0762f82 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/social-calendar/index.ejs @@ -0,0 +1,41 @@ +
+
+

+ 📅 Social Content Calendar +

+

Plan posts across 8 platforms — for Meg

+
+ +
+ +
+
+
+
+

Loading calendar...

+
+
+
+ + + + + diff --git a/services/arbiter-3.0/src/views/layout.ejs b/services/arbiter-3.0/src/views/layout.ejs index d90ba29..72f829a 100644 --- a/services/arbiter-3.0/src/views/layout.ejs +++ b/services/arbiter-3.0/src/views/layout.ejs @@ -103,9 +103,12 @@ 💬 Discord - + 📈 Social + + 📅 Social Calendar +
Infrastructure