Merge branch 'task-125-social-calendar'

This commit is contained in:
Claude
2026-04-12 00:54:22 +00:00
6 changed files with 349 additions and 1 deletions

View File

@@ -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);

View File

@@ -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("<div class='text-red-500 p-4'>Error loading calendar.</div>");
}
});
// 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(`<div class='text-red-500 text-xs'>${err.message}</div>`);
}
});
// 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(`<div class='text-red-500 text-xs'>${err.message}</div>`);
}
});
// 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(`<div class='text-red-500 text-xs'>${err.message}</div>`);
}
});
// 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;

View File

@@ -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 || ''); %>
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold dark:text-white"><%= isNew ? ' New Post Plan' : `✏️ Edit Post #${plan.id}` %></h2>
<button onclick="document.getElementById('spp-modal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
</div>
<form hx-post="<%= action %>" hx-swap="none" class="space-y-4">
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Scheduled Date & Time</label>
<input type="datetime-local" name="scheduled_at" value="<%= scheduledValue %>" required
class="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Platforms</label>
<div class="flex flex-wrap gap-2">
<% PLATFORMS.forEach(function(p) { %>
<label class="inline-flex items-center gap-1 px-2 py-1 border rounded cursor-pointer dark:border-gray-600 hover:bg-pink-50 dark:hover:bg-gray-700">
<input type="checkbox" name="platforms" value="<%= p %>" <%= (plan.platforms || []).includes(p) ? 'checked' : '' %>>
<span class="text-xs dark:text-gray-200"><%= p %></span>
</label>
<% }); %>
</div>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Status</label>
<select name="status" class="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm">
<% STATUSES.forEach(function(s) { %>
<option value="<%= s %>" <%= plan.status === s ? 'selected' : '' %>><%= s %></option>
<% }); %>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Caption / Body</label>
<textarea name="caption" rows="4" class="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm" placeholder="The actual post copy. Inline hashtags are fine here."><%= plan.caption || '' %></textarea>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Hashtags <span class="text-gray-400 font-normal">(space or comma separated, # optional)</span></label>
<input type="text" name="hashtags" value="<%= hashtagsValue %>" class="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm" placeholder="firefrost minecraft modded">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Media Notes <span class="text-gray-400 font-normal">(what to use — visual library coming soon)</span></label>
<textarea name="media_notes" rows="2" class="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm" placeholder="e.g. 'Trinity portrait, logos/firefrost-logo-dark.png'"><%= plan.media_notes || '' %></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Link (optional)</label>
<input type="url" name="link" value="<%= plan.link || '' %>" class="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm" placeholder="https://...">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Assigned To</label>
<select name="assigned_to" class="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm">
<option value="">— unassigned —</option>
<% ['Meg','Michael','Holly'].forEach(function(p) { %>
<option value="<%= p %>" <%= plan.assigned_to === p ? 'selected' : '' %>><%= p %></option>
<% }); %>
</select>
</div>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Internal Notes <span class="text-gray-400 font-normal">(not published)</span></label>
<textarea name="notes" rows="2" class="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"><%= plan.notes || '' %></textarea>
</div>
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<% if (!isNew) { %>
<button type="button" hx-post="/admin/social-calendar/<%= plan.id %>/delete" hx-swap="none"
hx-confirm="Delete this post plan?"
class="bg-red-600 hover:bg-red-700 text-white px-3 py-2 rounded text-sm">🗑 Delete</button>
<% } else { %><span></span><% } %>
<div class="flex gap-2">
<button type="button" onclick="document.getElementById('spp-modal').classList.add('hidden')" class="px-4 py-2 rounded border dark:border-gray-600 dark:text-gray-200 text-sm">Cancel</button>
<button type="submit" class="bg-pink-600 hover:bg-pink-700 text-white px-4 py-2 rounded text-sm font-medium"><%= isNew ? 'Create' : 'Save' %></button>
</div>
</div>
</form>
</div>

View File

@@ -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'}; %>
<div class="mb-4 flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg shadow p-3">
<button hx-get="/admin/social-calendar/week?week=<%= prevWeekISO %>" hx-target="#week-container"
class="px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm">← Prev</button>
<div class="text-center">
<div class="text-sm font-semibold dark:text-white">Week of <%= new Date(weekStartISO + 'T00:00:00').toLocaleDateString(undefined, {month:'long', day:'numeric', year:'numeric'}) %></div>
<button hx-get="/admin/social-calendar/week?week=<%= thisWeekISO %>" hx-target="#week-container"
class="text-xs text-blue-500 hover:underline">Jump to this week</button>
</div>
<button hx-get="/admin/social-calendar/week?week=<%= nextWeekISO %>" hx-target="#week-container"
class="px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm">Next →</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-7 gap-2">
<% Object.keys(byDay).forEach(function(iso) { %>
<% const day = byDay[iso]; %>
<% const dayName = day.date.toLocaleDateString(undefined, {weekday:'short'}); %>
<% const dayNum = day.date.getDate(); %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow min-h-[200px] flex flex-col">
<div class="px-3 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<div class="text-xs uppercase text-gray-500"><%= dayName %></div>
<div class="text-lg font-bold dark:text-white"><%= dayNum %></div>
</div>
<button hx-get="/admin/social-calendar/new?date=<%= iso %>" hx-target="#spp-modal-body" hx-swap="innerHTML"
onclick="document.getElementById('spp-modal').classList.remove('hidden')"
class="text-gray-400 hover:text-pink-500 text-xl leading-none" title="Add post for this day">+</button>
</div>
<div class="p-2 space-y-2 flex-1">
<% if (day.plans.length === 0) { %>
<div class="text-xs text-gray-400 italic text-center py-4">No posts</div>
<% } %>
<% day.plans.forEach(function(p) { %>
<div class="border border-gray-200 dark:border-gray-700 rounded p-2 hover:border-pink-400 cursor-pointer text-xs"
hx-get="/admin/social-calendar/<%= p.id %>/edit" hx-target="#spp-modal-body" hx-swap="innerHTML"
onclick="document.getElementById('spp-modal').classList.remove('hidden')">
<div class="flex items-center justify-between mb-1">
<div class="flex gap-1">
<% (p.platforms || []).forEach(function(pl) { %>
<span title="<%= pl %>"><%= platformIcons[pl] || pl %></span>
<% }); %>
</div>
<span class="text-gray-500 text-[10px]"><%= new Date(p.scheduled_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}) %></span>
</div>
<div class="dark:text-gray-200 line-clamp-2 mb-1"><%= p.caption || '(no caption)' %></div>
<span class="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold <%= statusColors[p.status] %>"><%= p.status %></span>
</div>
<% }); %>
</div>
</div>
<% }); %>
</div>

View File

@@ -0,0 +1,41 @@
<div class="mb-6 flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold dark:text-white flex items-center gap-2">
<span class="text-pink-500">📅</span> Social Content Calendar
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm">Plan posts across 8 platforms — for Meg</p>
</div>
<button hx-get="/admin/social-calendar/new" hx-target="#spp-modal-body" hx-swap="innerHTML"
onclick="document.getElementById('spp-modal').classList.remove('hidden')"
class="bg-pink-600 hover:bg-pink-700 text-white px-4 py-2 rounded-md text-sm font-medium shadow">
New Post
</button>
</div>
<div id="week-container"
hx-get="/admin/social-calendar/week?week=<%= weekStartISO %>"
hx-trigger="load, refresh-week from:body">
<div class="flex items-center justify-center h-64">
<div class="text-center">
<div class="text-4xl mb-4 animate-pulse">⏳</div>
<p class="text-gray-500 dark:text-gray-400">Loading calendar...</p>
</div>
</div>
</div>
<!-- Modal -->
<div id="spp-modal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" onclick="if(event.target===this) this.classList.add('hidden')">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div id="spp-modal-body"></div>
</div>
</div>
<script>
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.xhr.status === 200 || evt.detail.xhr.status === 201) {
if (evt.detail.xhr.getResponseHeader('HX-Trigger') === 'refresh-week') {
document.getElementById('spp-modal').classList.add('hidden');
}
}
});
</script>

View File

@@ -103,9 +103,12 @@
<a href="/admin/discord" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/discord') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
💬 Discord
</a>
<a href="/admin/social" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/social') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
<a href="/admin/social" class="block px-4 py-2 rounded-md <%= (currentPath === '/social' || (currentPath.startsWith('/social') && !currentPath.startsWith('/social-calendar'))) ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📈 Social
</a>
<a href="/admin/social-calendar" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/social-calendar') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📅 Social Calendar
</a>
<!-- Infrastructure -->
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">Infrastructure</div>