feat: add visual post calendar with drag-drop rescheduling (#160)
Universal: Auto Version Bump / Version Bump (push) Successful in 7s

Authored-by: Moko Consulting
This commit is contained in:
2026-06-28 11:51:05 -05:00
parent b03c7c6ba7
commit e67fbdfe2b
7 changed files with 511 additions and 0 deletions
+3
View File
@@ -2,6 +2,9 @@
## [Unreleased]
### Added
- **Visual post calendar**: FullCalendar-powered admin view with month/week/list modes (#160)
- **Post calendar**: color-coded events by status (posted/scheduled/queued/failed)
- **Post calendar**: drag-drop rescheduling with automatic status update
- **AI caption generation**: Generate platform-optimized cross-post captions from article content using Claude or OpenAI (#161)
- **AI provider config**: New "AI Caption Generation" fieldset in component options with provider, API key, model, and tone settings
- **AI Generate button**: One-click AI generation button in the Share Content panel that fills all caption fields
@@ -576,3 +576,16 @@ COM_MOKOSUITECROSS_AI_NOT_CONFIGURED="AI is not configured. Go to Options to set
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES="Category Rules"
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE="Category Routing"
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE_DESC="Category routing rules let you map Joomla categories to specific cross-post services. When rules exist for a category, only those services receive posts. When no rules exist, all services are used (default behaviour). Rules are managed in the database table #__mokosuitecross_category_rules. A full admin UI will be added in a future release."
; Post Calendar
COM_MOKOSUITECROSS_CALENDAR="Post Calendar"
COM_MOKOSUITECROSS_CALENDAR_DESC="Visual calendar of scheduled and posted content"
COM_MOKOSUITECROSS_SUBMENU_CALENDAR="Calendar"
COM_MOKOSUITECROSS_CALENDAR_TODAY="Today"
COM_MOKOSUITECROSS_CALENDAR_MONTH="Month"
COM_MOKOSUITECROSS_CALENDAR_WEEK="Week"
COM_MOKOSUITECROSS_CALENDAR_LIST="List"
COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_SUCCESS="Post rescheduled successfully"
COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR="Failed to reschedule post"
COM_MOKOSUITECROSS_CALENDAR_CANNOT_RESCHEDULE="Only scheduled or queued posts can be rescheduled"
COM_MOKOSUITECROSS_CALENDAR_LOAD_ERROR="Failed to load calendar events"
@@ -0,0 +1,268 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteCross\Administrator\Table\PostTable;
/**
* Calendar controller -- provides AJAX endpoints for the visual post calendar.
*
* Endpoints:
* task=calendar.events -- GET JSON feed for FullCalendar (filtered by start/end)
* task=calendar.reschedule -- POST reschedule a post to a new date/time
*/
class CalendarController extends BaseController
{
/**
* Return posts as FullCalendar-compatible JSON events.
*
* Query params: start, end (ISO 8601 date range from FullCalendar).
*
* @return void
*/
public function events(): void
{
$app = $this->app;
$db = Factory::getDbo();
// ACL check
if (!$app->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) {
$this->sendJsonResponse(['error' => 'Forbidden'], 403);
return;
}
// FullCalendar sends start/end as ISO date strings
$start = $this->input->getString('start', '');
$end = $this->input->getString('end', '');
$query = $db->getQuery(true)
->select([
'p.' . $db->quoteName('id'),
'p.' . $db->quoteName('article_id'),
'p.' . $db->quoteName('service_id'),
'p.' . $db->quoteName('status'),
'p.' . $db->quoteName('scheduled_at'),
'p.' . $db->quoteName('posted_at'),
'p.' . $db->quoteName('created'),
'p.' . $db->quoteName('message'),
'a.' . $db->quoteName('title', 'article_title'),
's.' . $db->quoteName('title', 'service_title'),
's.' . $db->quoteName('service_type'),
])
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->leftJoin(
$db->quoteName('#__content', 'a')
. ' ON ' . $db->quoteName('a.id') . ' = ' . $db->quoteName('p.article_id')
)
->leftJoin(
$db->quoteName('#__mokosuitecross_services', 's')
. ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')
)
->order($db->quoteName('p.created') . ' DESC');
// Filter by date range when provided
if ($start !== '') {
$dateExpr = 'COALESCE(p.scheduled_at, p.posted_at, p.created)';
$query->where($dateExpr . ' >= ' . $db->quote($start));
}
if ($end !== '') {
$dateExpr = 'COALESCE(p.scheduled_at, p.posted_at, p.created)';
$query->where($dateExpr . ' <= ' . $db->quote($end));
}
$db->setQuery($query);
$rows = $db->loadObjectList() ?: [];
// Map status to colour
$statusColors = [
'posted' => '#28a745',
'scheduled' => '#007bff',
'queued' => '#ffc107',
'failed' => '#dc3545',
'posting' => '#17a2b8',
];
$events = [];
foreach ($rows as $row) {
// Pick the best date for the calendar event
$eventDate = $row->scheduled_at ?: ($row->posted_at ?: $row->created);
// Skip rows with no usable date
if (empty($eventDate) || $eventDate === '0000-00-00 00:00:00') {
continue;
}
$title = ($row->article_title ?: 'Post #' . $row->id);
if ($row->service_title) {
$title .= ' - ' . $row->service_title;
}
$events[] = [
'id' => (int) $row->id,
'title' => $title,
'start' => $eventDate,
'color' => $statusColors[$row->status] ?? '#6c757d',
'url' => 'index.php?option=com_mokosuitecross&task=post.edit&id=' . (int) $row->id,
'extendedProps' => [
'status' => $row->status,
'service_type' => $row->service_type ?? '',
'article_id' => (int) $row->article_id,
'service_id' => (int) $row->service_id,
'message' => mb_substr($row->message ?? '', 0, 200),
],
];
}
$this->sendJsonResponse($events, 200);
}
/**
* Reschedule a post to a new date/time via drag-drop.
*
* POST params: post_id (int), new_date (ISO 8601 datetime string).
*
* @return void
*/
public function reschedule(): void
{
$app = $this->app;
// CSRF check
if (!Session::checkToken('post')) {
$this->sendJsonResponse(['error' => Text::_('JINVALID_TOKEN')], 403);
return;
}
// ACL check
if (!$app->getIdentity()->authorise('core.edit', 'com_mokosuitecross')) {
$this->sendJsonResponse(['error' => 'Forbidden'], 403);
return;
}
$postId = $this->input->getInt('post_id', 0);
$newDate = $this->input->getString('new_date', '');
if ($postId < 1 || $newDate === '') {
$this->sendJsonResponse(
['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')],
400
);
return;
}
// Validate the date format
try {
$dateObj = Factory::getDate($newDate);
} catch (\Exception $e) {
$this->sendJsonResponse(
['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')],
400
);
return;
}
// Load the post using Table bind/check/store pattern
$db = Factory::getDbo();
$table = new PostTable($db);
if (!$table->load($postId)) {
$this->sendJsonResponse(
['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')],
404
);
return;
}
// Only allow rescheduling of scheduled or queued posts
$allowedStatuses = ['scheduled', 'queued'];
if (!in_array($table->status, $allowedStatuses, true)) {
$this->sendJsonResponse(
['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')],
400
);
return;
}
// Update the post
$data = [
'scheduled_at' => $dateObj->toSql(),
'status' => 'scheduled',
'modified' => Factory::getDate()->toSql(),
];
if (!$table->bind($data) || !$table->check() || !$table->store()) {
$this->sendJsonResponse(
['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')],
500
);
return;
}
// Log the reschedule
$log = (object) [
'post_id' => $postId,
'service_id' => (int) $table->service_id,
'level' => 'info',
'message' => sprintf('Post rescheduled to %s via calendar drag-drop', $dateObj->toSql()),
'context' => '{}',
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitecross_logs', $log);
$this->sendJsonResponse(
[
'success' => true,
'message' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_SUCCESS'),
],
200
);
}
/**
* Send a JSON response and close the application.
*
* @param array $data Response data
* @param int $httpCode HTTP status code
*
* @return void
*/
private function sendJsonResponse(array $data, int $httpCode): void
{
$app = $this->app;
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
$app->setHeader('Status', (string) $httpCode);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$app->close();
}
}
@@ -40,6 +40,7 @@ class MokoSuiteCrossHelper
'posts' => 'COM_MOKOSUITECROSS_SUBMENU_POSTS',
'services' => 'COM_MOKOSUITECROSS_SUBMENU_SERVICES',
'templates' => 'COM_MOKOSUITECROSS_SUBMENU_TEMPLATES',
'calendar' => 'COM_MOKOSUITECROSS_SUBMENU_CALENDAR',
'logs' => 'COM_MOKOSUITECROSS_SUBMENU_LOGS',
];
@@ -0,0 +1,61 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\View\Calendar;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper;
class HtmlView extends BaseHtmlView
{
public $sidebar;
public $ajaxUrl;
public function display($tpl = null): void
{
// ACL check
$canDo = MokoSuiteCrossHelper::getActions();
if (!$canDo->get('core.manage')) {
throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
// Build AJAX URL for FullCalendar event source
$this->ajaxUrl = Route::_('index.php?option=com_mokosuitecross&task=calendar.events&format=json', false);
$this->addToolbar();
MokoSuiteCrossHelper::addSubmenu('calendar');
$this->sidebar = \Joomla\CMS\HTML\Sidebar::render();
// Set document title
Factory::getApplication()->getDocument()->setTitle(
Text::_('COM_MOKOSUITECROSS_CALENDAR') . ' - ' . Text::_('COM_MOKOSUITECROSS')
);
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(
Text::_('COM_MOKOSUITECROSS') . ' - ' . Text::_('COM_MOKOSUITECROSS_CALENDAR'),
'calendar'
);
ToolbarHelper::back('JTOOLBAR_BACK', Route::_('index.php?option=com_mokosuitecross&view=dashboard', false));
}
}
@@ -0,0 +1,161 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Session\Session;
/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Calendar\HtmlView $this */
$token = Session::getFormToken();
$ajaxUrl = $this->ajaxUrl;
?>
<style>
#mokosuitecross-calendar {
max-width: 1100px;
margin: 0 auto;
}
.fc .fc-toolbar-title {
font-size: 1.4em;
}
.mokosuitecross-calendar-legend {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.mokosuitecross-calendar-legend span {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
}
.mokosuitecross-calendar-legend .swatch {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 3px;
}
</style>
<div class="mokosuitecross-calendar-legend">
<span><span class="swatch" style="background:#28a745;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_STATUS_POSTED'); ?></span>
<span><span class="swatch" style="background:#007bff;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_STATUS_SCHEDULED'); ?></span>
<span><span class="swatch" style="background:#ffc107;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_STATUS_QUEUED'); ?></span>
<span><span class="swatch" style="background:#dc3545;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_STATUS_FAILED'); ?></span>
</div>
<div id="mokosuitecross-calendar"></div>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js" integrity="sha384-B1OFx8Gy9GjPu8UbUyXbGQpzll9ubAUQ9agInFJ8NnD7nYG1u/CLR+Sqr5yifl4q" crossorigin="anonymous"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('mokosuitecross-calendar');
var token = '<?php echo $token; ?>';
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,listWeek'
},
buttonText: {
today: '<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_TODAY', true); ?>',
month: '<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_MONTH', true); ?>',
week: '<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_WEEK', true); ?>',
list: '<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_LIST', true); ?>'
},
editable: true,
droppable: false,
navLinks: true,
dayMaxEvents: true,
eventSources: [{
url: '<?php echo $ajaxUrl; ?>',
method: 'GET',
failure: function() {
Joomla.renderMessages({
error: ['<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_LOAD_ERROR', true); ?>']
});
}
}],
eventClick: function(info) {
info.jsEvent.preventDefault();
if (info.event.url) {
window.location.href = info.event.url;
}
},
eventDrop: function(info) {
var postId = info.event.id;
var status = info.event.extendedProps.status;
// Only allow rescheduling of scheduled or queued posts
if (status !== 'scheduled' && status !== 'queued') {
info.revert();
Joomla.renderMessages({
warning: ['<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_CANNOT_RESCHEDULE', true); ?>']
});
return;
}
var newDate = info.event.start.toISOString();
var formData = new FormData();
formData.append('post_id', postId);
formData.append('new_date', newDate);
formData.append(token, '1');
fetch('index.php?option=com_mokosuitecross&task=calendar.reschedule&format=json', {
method: 'POST',
body: formData
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
// Update the event colour to scheduled
info.event.setProp('color', '#007bff');
info.event.setExtendedProp('status', 'scheduled');
Joomla.renderMessages({
message: ['<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_SUCCESS', true); ?>']
});
} else {
info.revert();
Joomla.renderMessages({
error: [data.error || '<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR', true); ?>']
});
}
})
.catch(function() {
info.revert();
Joomla.renderMessages({
error: ['<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR', true); ?>']
});
});
},
eventDidMount: function(info) {
// Add tooltip with post details
var props = info.event.extendedProps;
var tip = info.event.title;
if (props.status) {
tip += ' [' + props.status + ']';
}
if (props.message) {
tip += '\n' + props.message;
}
info.el.setAttribute('title', tip);
}
});
calendar.render();
});
</script>
@@ -282,6 +282,10 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
class="list-group-item list-group-item-action">
<?php echo Text::_('COM_MOKOSUITECROSS_SUBMENU_LOGS'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar'); ?>"
class="list-group-item list-group-item-action">
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR'); ?>
</a>
</div>
</div>
</div>