feat: add visual post calendar with drag-drop rescheduling (#160)
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Generic: Project CI / Lint & Validate (pull_request) Successful in 50s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 52s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Generic: Project CI / Lint & Validate (pull_request) Successful in 50s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 52s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Authored-by: Moko Consulting
This commit is contained in:
@@ -570,65 +570,22 @@ COM_MOKOSUITECROSS_AI_GENERATE_DESC="Generate platform-optimized captions from t
|
||||
COM_MOKOSUITECROSS_AI_GENERATING="Generating captions..."
|
||||
COM_MOKOSUITECROSS_AI_GENERATED="AI captions generated successfully."
|
||||
COM_MOKOSUITECROSS_AI_ERROR="AI generation failed: %s"
|
||||
COM_MOKOSUITECROSS_AI_NOT_CONFIGURED="AI is not configured. Go to Options to set up a provider and API key."
|
||||
|
||||
; Social Image Generator
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE="Social Image Generator"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED="Enable Social Images"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED_DESC="Generate branded OG images with article title overlay for social sharing."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR="Background Color"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR_DESC="Hex color for the image background (e.g. #1a1a2e)."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR="Text Color"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC="Hex color for the title text overlay."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE="Font Size"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE_DESC="Font size in pixels for the title text (24-96)."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME="Show Site Name"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME_DESC="Display the site name in the bottom-right corner of generated images."
|
||||
COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATE="Generate Social Image"
|
||||
COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATING="Generating image..."
|
||||
COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATED="Social image generated."
|
||||
COM_MOKOSUITECROSS_SOCIAL_IMAGE_ERROR="Image generation failed: %s"
|
||||
COM_MOKOSUITECROSS_SOCIAL_IMAGE_NOT_CONFIGURED="Social image generator is not enabled. Go to Options to enable it."
|
||||
|
||||
; Analytics
|
||||
COM_MOKOSUITECROSS_SUBMENU_ANALYTICS="Analytics"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_PERIOD="Time Period"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_SERVICE_FILTER="Service"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES="All Services"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Engagement Heatmap"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_HOURLY="Hourly Distribution"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAILY="Day of Week Distribution"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="Not enough posting data to generate recommendations. Post at least 3 times per time slot over the selected period."
|
||||
COM_MOKOSUITECROSS_ANALYTICS_POSTS_SUCCESS="%d of %d successful"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_SUN="Sun"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_MON="Mon"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_TUE="Tue"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_WED="Wed"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_THU="Thu"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_FRI="Fri"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_DAY_SAT="Sat"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_HIGH="High success rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_MEDIUM="Medium success rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_LOW="Low success rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_NONE="No data"
|
||||
COM_MOKOSUITECROSS_PERIOD_180_DAYS="Last 180 days"
|
||||
COM_MOKOSUITECROSS_PERIOD_365_DAYS="Last 365 days"
|
||||
|
||||
|
||||
; Analytics
|
||||
COM_MOKOSUITECROSS_ANALYTICS="Analytics"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Engagement Heatmap"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="Not enough data yet. Analytics will appear after posts collect engagement metrics."
|
||||
COM_MOKOSUITECROSS_ANALYTICS_ENGAGEMENT_RATE="Engagement Rate"
|
||||
COM_MOKOSUITECROSS_ANALYTICS_ALL_PLATFORMS="All Platforms"
|
||||
; Category Rules
|
||||
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."
|
||||
|
||||
; Calendar View
|
||||
COM_MOKOSUITECROSS_CALENDAR_PREV_MONTH="Previous"
|
||||
COM_MOKOSUITECROSS_CALENDAR_NEXT_MONTH="Next"
|
||||
; 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_SUBMENU_CALENDAR="Post Calendar"
|
||||
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"
|
||||
|
||||
@@ -13,12 +13,256 @@ 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
|
||||
{
|
||||
public function display($cachable = false, $urlparams = []): static
|
||||
/**
|
||||
* Return posts as FullCalendar-compatible JSON events.
|
||||
*
|
||||
* Query params: start, end (ISO 8601 date range from FullCalendar).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function events(): void
|
||||
{
|
||||
return parent::display($cachable, $urlparams);
|
||||
$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',
|
||||
'calendar' => 'COM_MOKOSUITECROSS_SUBMENU_CALENDAR',
|
||||
'analytics' => 'COM_MOKOSUITECROSS_SUBMENU_ANALYTICS',
|
||||
|
||||
@@ -14,52 +14,48 @@ 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 int $year;
|
||||
public int $month;
|
||||
public array $events;
|
||||
public $sidebar;
|
||||
public $ajaxUrl;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$input = Factory::getApplication()->input;
|
||||
// ACL check
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
$this->year = $input->getInt('year', (int) date('Y'));
|
||||
$this->month = $input->getInt('month', (int) date('n'));
|
||||
|
||||
if ($this->month < 1 || $this->month > 12) {
|
||||
$this->month = (int) date('n');
|
||||
if (!$canDo->get('core.manage')) {
|
||||
throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
|
||||
}
|
||||
|
||||
if ($this->year < 2000 || $this->year > 2100) {
|
||||
$this->year = (int) date('Y');
|
||||
}
|
||||
|
||||
$model = $this->getModel();
|
||||
$this->events = $model->getEvents($this->year, $this->month);
|
||||
// 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
|
||||
{
|
||||
$canDo = MokoSuiteCrossHelper::getActions();
|
||||
|
||||
ToolbarHelper::title('MokoSuiteCross -- Post Calendar', 'calendar');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuitecross&view=dashboard');
|
||||
|
||||
if ($canDo->get('core.admin')) {
|
||||
ToolbarHelper::preferences('com_mokosuitecross');
|
||||
}
|
||||
ToolbarHelper::title(
|
||||
Text::_('COM_MOKOSUITECROSS') . ' - ' . Text::_('COM_MOKOSUITECROSS_CALENDAR'),
|
||||
'calendar'
|
||||
);
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', Route::_('index.php?option=com_mokosuitecross&view=dashboard', false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,118 +12,150 @@
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Calendar\HtmlView $this */
|
||||
|
||||
$year = $this->year;
|
||||
$month = $this->month;
|
||||
$events = $this->events;
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$prevMonth = $month - 1;
|
||||
$prevYear = $year;
|
||||
|
||||
if ($prevMonth < 1) {
|
||||
$prevMonth = 12;
|
||||
$prevYear--;
|
||||
}
|
||||
|
||||
$nextMonth = $month + 1;
|
||||
$nextYear = $year;
|
||||
|
||||
if ($nextMonth > 12) {
|
||||
$nextMonth = 1;
|
||||
$nextYear++;
|
||||
}
|
||||
|
||||
$monthName = date('F', mktime(0, 0, 0, $month, 1, $year));
|
||||
$daysInMonth = (int) date('t', mktime(0, 0, 0, $month, 1, $year));
|
||||
$firstWeekday = ((int) date('N', mktime(0, 0, 0, $month, 1, $year))) - 1;
|
||||
|
||||
$statusClass = static function (string $status): string {
|
||||
return match ($status) {
|
||||
'posted' => 'bg-success',
|
||||
'failed' => 'bg-danger',
|
||||
default => 'bg-warning text-dark',
|
||||
};
|
||||
};
|
||||
$token = Session::getFormToken();
|
||||
$ajaxUrl = $this->ajaxUrl;
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar&year=' . $prevYear . '&month=' . $prevMonth); ?>"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<span class="icon-chevron-left" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_PREV_MONTH'); ?>
|
||||
</a>
|
||||
<h3 class="mb-0"><?php echo htmlspecialchars($monthName . ' ' . $year); ?></h3>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar&year=' . $nextYear . '&month=' . $nextMonth); ?>"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_NEXT_MONTH'); ?>
|
||||
<span class="icon-chevron-right" aria-hidden="true"></span>
|
||||
</a>
|
||||
<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 class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:14.28%"><?php echo Text::_('MON'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('TUE'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('WED'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('THU'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('FRI'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('SAT'); ?></th>
|
||||
<th style="width:14.28%"><?php echo Text::_('SUN'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$day = 1;
|
||||
$started = false;
|
||||
<div id="mokosuitecross-calendar"></div>
|
||||
|
||||
while ($day <= $daysInMonth) : ?>
|
||||
<tr>
|
||||
<?php for ($col = 0; $col < 7; $col++) :
|
||||
if (!$started && $col < $firstWeekday) : ?>
|
||||
<td class="text-muted bg-light"> </td>
|
||||
<?php
|
||||
continue;
|
||||
endif;
|
||||
<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; ?>';
|
||||
|
||||
$started = true;
|
||||
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;
|
||||
|
||||
if ($day > $daysInMonth) : ?>
|
||||
<td class="text-muted bg-light"> </td>
|
||||
<?php
|
||||
continue;
|
||||
endif;
|
||||
// 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;
|
||||
}
|
||||
|
||||
$dateKey = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
$isToday = ($dateKey === $today);
|
||||
$cellClass = $isToday ? 'border border-primary border-2 bg-primary bg-opacity-10' : '';
|
||||
$dayEvents = $events[$dateKey] ?? [];
|
||||
?>
|
||||
<td class="<?php echo $cellClass; ?>" style="vertical-align: top; min-height: 80px;">
|
||||
<div class="fw-bold mb-1<?php echo $isToday ? ' text-primary' : ''; ?>">
|
||||
<?php echo $day; ?>
|
||||
<?php if ($isToday) : ?>
|
||||
<small class="text-primary"><?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_TODAY'); ?></small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php foreach ($dayEvents as $event) : ?>
|
||||
<span class="badge <?php echo $statusClass($event->status); ?> mb-1 d-block text-truncate" style="max-width: 100%;"
|
||||
title="<?php echo htmlspecialchars(ucfirst($event->service_type) . ': ' . $event->article_title . ' (' . $event->status . ')'); ?>">
|
||||
<?php echo htmlspecialchars(ucfirst($event->service_type)); ?>:
|
||||
<?php echo htmlspecialchars(mb_substr($event->article_title, 0, 20)); ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
<?php
|
||||
$day++;
|
||||
endfor; ?>
|
||||
</tr>
|
||||
<?php endwhile; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
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>
|
||||
|
||||
@@ -220,175 +220,6 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Analytics: Best Times to Post Heatmap -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES'); ?></h5>
|
||||
<select id="heatmapServiceFilter" class="form-select form-select-sm" style="width: auto;">
|
||||
<option value=""><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_ALL_PLATFORMS'); ?></option>
|
||||
<?php
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$stQuery = $db->getQuery(true)
|
||||
->select('DISTINCT ' . $db->quoteName('service_type'))
|
||||
->from($db->quoteName('#__mokosuitecross_services'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('service_type') . ' ASC');
|
||||
$db->setQuery($stQuery);
|
||||
$serviceTypes = $db->loadColumn();
|
||||
foreach ($serviceTypes as $st) :
|
||||
?>
|
||||
<option value="<?php echo htmlspecialchars($st); ?>"><?php echo htmlspecialchars(ucfirst($st)); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="heatmapContainer">
|
||||
<p class="text-muted" id="heatmapLoading"><?php echo Text::_('JLIB_HTML_BEHAVIOR_LOADING'); ?></p>
|
||||
<div id="heatmapNoData" style="display:none;">
|
||||
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_NO_DATA'); ?></p>
|
||||
</div>
|
||||
<div id="heatmapGrid" style="display:none;">
|
||||
<style>
|
||||
.msc-heatmap { border-collapse: collapse; width: 100%; font-size: 11px; }
|
||||
.msc-heatmap th, .msc-heatmap td { text-align: center; padding: 3px 2px; min-width: 28px; }
|
||||
.msc-heatmap th { font-weight: 600; color: #666; font-size: 10px; }
|
||||
.msc-heatmap td.msc-hm-cell { border-radius: 3px; cursor: default; position: relative; }
|
||||
.msc-heatmap td.msc-hm-cell:hover { outline: 2px solid #333; z-index: 1; }
|
||||
.msc-heatmap .msc-hm-day { text-align: right; padding-right: 8px; font-weight: 600; color: #555; white-space: nowrap; }
|
||||
</style>
|
||||
<table class="msc-heatmap" id="heatmapTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<?php for ($h = 0; $h < 24; $h++) :
|
||||
$label = $h % 12 ?: 12;
|
||||
$suffix = $h < 12 ? 'a' : 'p';
|
||||
?>
|
||||
<th><?php echo $label . $suffix; ?></th>
|
||||
<?php endfor; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
for ($d = 0; $d < 7; $d++) :
|
||||
?>
|
||||
<tr>
|
||||
<td class="msc-hm-day"><?php echo $dayLabels[$d]; ?></td>
|
||||
<?php for ($h = 0; $h < 24; $h++) : ?>
|
||||
<td class="msc-hm-cell" id="hm-<?php echo $d; ?>-<?php echo $h; ?>" title="<?php echo $dayLabels[$d] . ' ' . ($h % 12 ?: 12) . ':00 ' . ($h < 12 ? 'AM' : 'PM'); ?>"></td>
|
||||
<?php endfor; ?>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="d-flex align-items-center justify-content-end mt-2" style="font-size:11px;color:#666;">
|
||||
<span class="me-1"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_ENGAGEMENT_RATE'); ?>:</span>
|
||||
<span style="display:inline-block;width:14px;height:14px;background:#ebedf0;border-radius:2px;margin:0 1px;" title="0%"></span>
|
||||
<span style="display:inline-block;width:14px;height:14px;background:#9be9a8;border-radius:2px;margin:0 1px;" title="Low"></span>
|
||||
<span style="display:inline-block;width:14px;height:14px;background:#40c463;border-radius:2px;margin:0 1px;" title="Medium"></span>
|
||||
<span style="display:inline-block;width:14px;height:14px;background:#30a14e;border-radius:2px;margin:0 1px;" title="High"></span>
|
||||
<span style="display:inline-block;width:14px;height:14px;background:#216e39;border-radius:2px;margin:0 1px;" title="Very High"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="heatmapBestTimes" style="display:none;" class="mt-3 pt-3 border-top">
|
||||
<strong><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES'); ?>:</strong>
|
||||
<ul id="bestTimesList" class="mb-0 mt-1"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo \Joomla\CMS\Session\Session::getFormToken(); ?>';
|
||||
|
||||
function loadHeatmap(serviceType) {
|
||||
var url = 'index.php?option=com_mokosuitecross&task=analytics.heatmap&format=json'
|
||||
+ '&service_type=' + encodeURIComponent(serviceType || '')
|
||||
+ '&days=90&' + token + '=1';
|
||||
|
||||
document.getElementById('heatmapLoading').style.display = '';
|
||||
document.getElementById('heatmapGrid').style.display = 'none';
|
||||
document.getElementById('heatmapNoData').style.display = 'none';
|
||||
document.getElementById('heatmapBestTimes').style.display = 'none';
|
||||
|
||||
fetch(url)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
document.getElementById('heatmapLoading').style.display = 'none';
|
||||
|
||||
if (!data.success) {
|
||||
document.getElementById('heatmapNoData').style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
var grid = data.grid;
|
||||
var maxRate = 0;
|
||||
var hasData = false;
|
||||
|
||||
for (var d = 0; d < 7; d++) {
|
||||
for (var h = 0; h < 24; h++) {
|
||||
var rate = grid[d] && grid[d][h] ? parseFloat(grid[d][h].avg_rate) : 0;
|
||||
if (rate > maxRate) maxRate = rate;
|
||||
if (rate > 0) hasData = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasData) {
|
||||
document.getElementById('heatmapNoData').style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('heatmapGrid').style.display = '';
|
||||
|
||||
var colors = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'];
|
||||
for (var d = 0; d < 7; d++) {
|
||||
for (var h = 0; h < 24; h++) {
|
||||
var cell = document.getElementById('hm-' + d + '-' + h);
|
||||
if (!cell) continue;
|
||||
var val = grid[d] && grid[d][h] ? grid[d][h] : {avg_rate: 0, post_count: 0};
|
||||
var rate = parseFloat(val.avg_rate);
|
||||
var count = parseInt(val.post_count, 10);
|
||||
var level = 0;
|
||||
if (maxRate > 0 && rate > 0) {
|
||||
var pct = rate / maxRate;
|
||||
if (pct <= 0.25) level = 1;
|
||||
else if (pct <= 0.50) level = 2;
|
||||
else if (pct <= 0.75) level = 3;
|
||||
else level = 4;
|
||||
}
|
||||
cell.style.backgroundColor = colors[level];
|
||||
cell.title = cell.title.split(' - ')[0] + ' - ' + rate.toFixed(1) + '% (' + count + ' posts)';
|
||||
}
|
||||
}
|
||||
|
||||
// Show best times
|
||||
if (data.best_times && data.best_times.length > 0) {
|
||||
document.getElementById('heatmapBestTimes').style.display = '';
|
||||
var list = document.getElementById('bestTimesList');
|
||||
list.innerHTML = '';
|
||||
var top = data.best_times.slice(0, 3);
|
||||
for (var i = 0; i < top.length; i++) {
|
||||
var bt = top[i];
|
||||
var li = document.createElement('li');
|
||||
li.textContent = bt.day_name + ' at ' + bt.hour_label + ' (' + bt.avg_rate.toFixed(1) + '% avg engagement)';
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('heatmapLoading').style.display = 'none';
|
||||
document.getElementById('heatmapNoData').style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('heatmapServiceFilter').addEventListener('change', function() {
|
||||
loadHeatmap(this.value);
|
||||
});
|
||||
|
||||
loadHeatmap('');
|
||||
});
|
||||
</script>
|
||||
<!-- Recent Activity -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
@@ -451,9 +282,9 @@ $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=analytics'); ?>"
|
||||
<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_SUBMENU_ANALYTICS'); ?>
|
||||
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR'); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user