feat(admin): Task #125 — Social Content Calendar widget
Week-at-a-glance post planning for Meg across 8 platforms (tiktok, facebook, instagram, x, bluesky, youtube, twitch, reddit). - DB: extends social_platform enum with youtube/twitch/reddit and adds new social_post_plans table with scheduled_at, platforms[], status (draft/ready/scheduled/published/skipped), caption, hashtags[], media_notes, link, assigned_to, notes, created_by - src/routes/admin/social-calendar.js: shell, HTMX week view, create, update, delete, edit/new form endpoints. Week-based navigation with prev/next/this-week. Hashtag parsing dedupes and strips leading # - src/views/admin/social-calendar/: index.ejs shell with modal, _week.ejs 7-column grid with per-day quick-add, _form.ejs full CRUD form with platform checkboxes, datetime picker, status dropdown, both free-form caption and dedicated hashtag field - Nav link added under Community, with corrected highlight logic so /admin/social and /admin/social-calendar don't both light up Separate table from social_posts (which remains the analytics table). Plans and published posts are distinct concerns. Chronicler #81
This commit is contained in:
@@ -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);
|
||||
|
||||
160
services/arbiter-3.0/src/routes/admin/social-calendar.js
Normal file
160
services/arbiter-3.0/src/routes/admin/social-calendar.js
Normal 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;
|
||||
@@ -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">×</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user