diff --git a/ai_content_strategy.libraries.yml b/ai_content_strategy.libraries.yml index eab3ec8..86018e0 100644 --- a/ai_content_strategy.libraries.yml +++ b/ai_content_strategy.libraries.yml @@ -2,12 +2,25 @@ content_strategy: version: VERSION css: theme: - css/content-strategy.css: {} + css/components/base.css: {} + css/components/cards.css: {} + css/components/table.css: {} + css/components/checkbox.css: {} + css/components/links.css: {} + css/components/delete.css: {} + css/components/editable.css: {} + css/components/icons.css: {} js: - js/content-ideas.js: {} + js/content-strategy-utils.js: {} + js/content-strategy-generate.js: {} + js/content-strategy-editable.js: {} + js/content-strategy-delete.js: {} + js/content-strategy-checkbox.js: {} + js/content-strategy-links.js: {} + js/content-strategy-export.js: {} dependencies: - core/drupal - core/jquery - core/once - core/drupal.ajax - - core/drupal.message + - core/drupal.message diff --git a/ai_content_strategy.module b/ai_content_strategy.module index 3088e34..06e81d2 100644 --- a/ai_content_strategy.module +++ b/ai_content_strategy.module @@ -39,8 +39,8 @@ function ai_content_strategy_get_button_texts() { foreach ($categories as $category) { $category_id = $category->id(); $button_texts['generate'][$category_id] = t('Generate AI recommendations'); - $button_texts['generate_more'][$category_id] = t('Add more AI @type', ['@type' => t('ideas')]); - $button_texts['add_more'][$category_id] = t('Add more AI recommendations'); + $button_texts['generate_more'][$category_id] = t('Generate more AI @type', ['@type' => t('ideas')]); + $button_texts['add_more'][$category_id] = t('Generate more AI recommendations'); } } @@ -66,6 +66,48 @@ function ai_content_strategy_theme() { 'button_text' => [], ], ], + 'ai_content_strategy_icon' => [ + 'variables' => [ + 'name' => NULL, + 'class' => NULL, + 'size' => NULL, + ], + 'template' => 'components/icon', + ], + 'ai_content_strategy_link_display' => [ + 'variables' => [ + 'link' => NULL, + 'section' => NULL, + 'uuid' => NULL, + 'idea_uuid' => NULL, + ], + 'template' => 'components/link-display', + ], + 'ai_content_strategy_link_add_button' => [ + 'variables' => [ + 'section' => NULL, + 'uuid' => NULL, + 'idea_uuid' => NULL, + ], + 'template' => 'components/link-add-button', + ], + 'ai_content_strategy_link_input' => [ + 'variables' => [ + 'current_link' => '', + ], + 'template' => 'components/link-input', + ], + 'ai_content_strategy_idea_row' => [ + 'variables' => [ + 'section' => NULL, + 'uuid' => NULL, + 'idea_uuid' => NULL, + 'idea_text' => NULL, + 'idea_implemented' => FALSE, + 'idea_link' => '', + ], + 'template' => 'components/idea-row', + ], ]; } @@ -102,8 +144,15 @@ function ai_content_strategy_preprocess_ai_content_strategy_recommendations(&$va } } - // Add to JavaScript settings. + // Add JavaScript settings (icons now use CSS mask-image). $variables['#attached']['drupalSettings']['aiContentStrategy'] = [ 'buttonText' => $variables['button_text'], + 'translations' => [ + 'addLink' => t('+ Add link'), + 'editLink' => t('Edit link'), + 'save' => t('Save'), + 'cancel' => t('Cancel'), + 'enterUrl' => t('Enter URL...'), + ], ]; } diff --git a/ai_content_strategy.routing.yml b/ai_content_strategy.routing.yml index 4dcbbb6..4a0d234 100644 --- a/ai_content_strategy.routing.yml +++ b/ai_content_strategy.routing.yml @@ -17,7 +17,7 @@ ai_content_strategy.recommendations.generate: _admin_route: TRUE ai_content_strategy.generate_more: - path: '/admin/reports/ai/content-strategy/generate-more/{section}/{title}' + path: '/admin/reports/ai/content-strategy/generate-more/{section}/{uuid}' defaults: _controller: '\Drupal\ai_content_strategy\Controller\ContentStrategyController::generateMore' _title: 'Generate More Ideas' @@ -27,7 +27,7 @@ ai_content_strategy.generate_more: parameters: section: type: string - title: + uuid: type: string ai_content_strategy.add_more_recommendations: @@ -40,6 +40,51 @@ ai_content_strategy.add_more_recommendations: options: _admin_route: TRUE +ai_content_strategy.delete_card: + path: '/admin/reports/ai/content-strategy/delete-card/{section}/{uuid}' + defaults: + _controller: '\Drupal\ai_content_strategy\Controller\ContentStrategyController::deleteCard' + _title: 'Delete Card' + requirements: + _permission: 'access ai content strategy' + options: + parameters: + section: + type: string + uuid: + type: string + +ai_content_strategy.delete_idea: + path: '/admin/reports/ai/content-strategy/delete-idea/{section}/{uuid}/{idea_uuid}' + defaults: + _controller: '\Drupal\ai_content_strategy\Controller\ContentStrategyController::deleteIdea' + _title: 'Delete Content Idea' + requirements: + _permission: 'access ai content strategy' + options: + parameters: + section: + type: string + uuid: + type: string + idea_uuid: + type: string + +ai_content_strategy.save_card: + path: '/admin/reports/ai/content-strategy/save-card/{section}/{uuid}' + defaults: + _controller: '\Drupal\ai_content_strategy\Controller\ContentStrategyController::saveCard' + _title: 'Save Card' + requirements: + _permission: 'access ai content strategy' + methods: [POST] + options: + parameters: + section: + type: string + uuid: + type: string + ai_content_strategy.settings: path: '/admin/config/ai/content-strategy/settings' defaults: diff --git a/ai_content_strategy.services.yml b/ai_content_strategy.services.yml index 6697984..afdb5eb 100644 --- a/ai_content_strategy.services.yml +++ b/ai_content_strategy.services.yml @@ -3,6 +3,18 @@ services: parent: logger.channel_base arguments: ['ai_content_strategy'] + ai_content_strategy.recommendation_storage: + class: Drupal\ai_content_strategy\Service\RecommendationStorageService + arguments: ['@keyvalue', '@datetime.time', '@uuid'] + + ai_content_strategy.idea_row_builder: + class: Drupal\ai_content_strategy\Service\IdeaRowBuilder + arguments: ['@renderer'] + + ai_content_strategy.ajax_response_builder: + class: Drupal\ai_content_strategy\Service\AjaxResponseBuilder + arguments: ['@date.formatter'] + ai_content_strategy.category_schema_builder: class: Drupal\ai_content_strategy\Service\CategorySchemaBuilder arguments: ['@entity_type.manager', '@cache.default'] diff --git a/css/components/base.css b/css/components/base.css new file mode 100644 index 0000000..cff9e33 --- /dev/null +++ b/css/components/base.css @@ -0,0 +1,62 @@ +/** + * @file + * Base layout styles for AI Content Strategy. + */ + +/* Main Container */ +.content-strategy-recommendations { + max-width: 1200px; + margin: 2em auto; + padding: 0 1em; +} + +.content-strategy-description { + margin-bottom: 2em; + font-size: 1.1em; + color: #666; +} + +/* Status Area */ +.content-strategy-status { + display: flex; + flex-wrap: wrap; + gap: 2rem; + padding: 1rem; + background: #f8f9fa; + border: 1px solid #e5e7eb; + border-radius: 6px; + margin-bottom: 1.5rem; +} + +.status-item { + display: flex; + align-items: baseline; + gap: 0.5rem; + font-size: 0.95em; +} + +.status-item strong { + color: #374151; + font-weight: 600; +} + +.status-item:not(strong) { + color: #6b7280; +} + +/* Actions */ +.content-strategy-actions { + display: flex; + gap: 1rem; + align-items: center; + margin-bottom: 2rem; +} + +/* Empty State */ +.empty-recommendations { + text-align: center; + padding: 3rem; + background: #f9fafb; + border-radius: 8px; + color: #6b7280; +} diff --git a/css/content-strategy.css b/css/components/cards.css similarity index 69% rename from css/content-strategy.css rename to css/components/cards.css index e888e01..77194e2 100644 --- a/css/content-strategy.css +++ b/css/components/cards.css @@ -1,28 +1,7 @@ -/* Main Container */ -.content-strategy-recommendations { - max-width: 1200px; - margin: 2em auto; - padding: 0 1em; -} - -.content-strategy-description { - margin-bottom: 2em; - font-size: 1.1em; - color: #666; -} - -/* Actions */ -.content-strategy-actions { - display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 2rem; -} - -.last-run-time { - color: #666; - font-size: 0.9em; -} +/** + * @file + * Recommendation card styles for AI Content Strategy. + */ /* Recommendations Grid */ .recommendation-items { @@ -47,11 +26,32 @@ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } +.recommendation-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.5rem; +} + +.recommendation-header h4 { + flex: 1; + margin: 0; +} + .recommendation-item p { margin-bottom: 1rem; color: #4a4a4a; } +/* Recommendation Actions */ +.recommendation-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; +} + /* Priority Badges */ .priority-badge { display: inline-block; @@ -80,12 +80,3 @@ color: #16a34a; border: 1px solid #dcfce7; } - -/* Empty State */ -.empty-recommendations { - text-align: center; - padding: 3rem; - background: #f9fafb; - border-radius: 8px; - color: #6b7280; -} \ No newline at end of file diff --git a/css/components/checkbox.css b/css/components/checkbox.css new file mode 100644 index 0000000..640481a --- /dev/null +++ b/css/components/checkbox.css @@ -0,0 +1,61 @@ +/** + * @file + * Checkbox styles for AI Content Strategy implemented status. + */ + +/* Idea Checkbox */ +.idea-checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.idea-checkbox input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.idea-checkbox-visual { + display: inline-block; + width: 25px; + height: 25px; + border: 2px solid #d1d5db; + border-radius: 4px; + background-color: #fff; + transition: all 0.15s ease; + position: relative; +} + +.idea-checkbox-visual::after { + content: ''; + position: absolute; + display: none; + left: 8px; + top: 3px; + width: 6px; + height: 12px; + border: solid #fff; + border-width: 0 2.5px 2.5px 0; + transform: rotate(45deg); +} + +.idea-checkbox input[type="checkbox"]:checked + .idea-checkbox-visual { + background-color: #16a34a; + border-color: #16a34a; +} + +.idea-checkbox input[type="checkbox"]:checked + .idea-checkbox-visual::after { + display: block; +} + +.idea-checkbox:hover .idea-checkbox-visual { + border-color: #16a34a; +} + +.idea-checkbox input[type="checkbox"]:focus + .idea-checkbox-visual { + outline: 2px solid #16a34a; + outline-offset: 2px; +} diff --git a/css/components/delete.css b/css/components/delete.css new file mode 100644 index 0000000..72df9cc --- /dev/null +++ b/css/components/delete.css @@ -0,0 +1,88 @@ +/** + * @file + * Delete button styles for AI Content Strategy. + */ + +/* Delete Idea Button */ +.delete-idea-link { + background: none; + border: none; + padding: 0; + width: 25px; + height: 25px; + color: #dc2626; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + opacity: 0; +} + +.content-ideas-table tbody tr:hover .delete-idea-link { + opacity: 0.6; +} + +.delete-idea-link:hover { + opacity: 1 !important; + background-color: #fee2e2; +} + +/* Legacy SVG support - can be removed when all icons use CSS mask-image */ +.delete-idea-link svg { + width: 20px; + height: 20px; + min-width: 20px; + min-height: 20px; + flex-shrink: 0; +} + +.delete-idea-link:focus { + outline: 2px solid #dc2626; + outline-offset: 2px; + opacity: 1; +} + +/* Delete Card Button (in card header) */ +.delete-card-link { + background: none; + border: none; + padding: 0.5rem; + color: #dc2626; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + flex-shrink: 0; + opacity: 0; +} + +.recommendation-item:hover .delete-card-link { + opacity: 0.6; +} + +.delete-card-link:hover { + opacity: 1 !important; + color: #991b1b; + background-color: #fee2e2; +} + +.delete-card-link svg { + width: 16px; + height: 16px; + transition: transform 0.2s ease; +} + +.delete-card-link:hover svg { + transform: scale(1.1); +} + +.delete-card-link:focus { + outline: 2px solid #dc2626; + outline-offset: 2px; + color: #dc2626; + opacity: 1; +} diff --git a/css/components/editable.css b/css/components/editable.css new file mode 100644 index 0000000..9199e7e --- /dev/null +++ b/css/components/editable.css @@ -0,0 +1,116 @@ +/** + * @file + * Editable field styles for AI Content Strategy. + */ + +/* Editable Fields */ +.editable-field { + cursor: text; + position: relative; + transition: background-color 0.2s ease, outline-color 0.2s ease; +} + +.editable-field:hover { + outline: 1px dashed #ccc; + outline-offset: 2px; +} + +.editable-field:focus { + outline: 2px solid #0073aa; + outline-offset: 2px; + background-color: #f9f9f9; +} + +/* Editable Field States */ +.editable-field--saving { + outline: 2px solid #0073aa; + outline-offset: 2px; + background-color: #f0f7ff; +} + +.editable-field--saved { + animation: field-saved-flash 0.6s ease-out; +} + +@keyframes field-saved-flash { + 0% { + background-color: #d4edda; + outline: 2px solid #28a745; + outline-offset: 2px; + } + 100% { + background-color: transparent; + outline: none; + } +} + +/* Inline Save Indicator (per field) */ +.field-save-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 8px; + vertical-align: middle; +} + +/* Adjust Drupal core/Claro throbber when used inline */ +.field-save-indicator .ajax-progress, +.field-save-indicator .ajax-progress-throbber, +.field-save-indicator .ajax-progress--throbber { + margin: 0; + padding: 0; +} + +/* Checkmark Icon */ +.field-save-indicator__checkmark { + display: inline-flex; + color: #28a745; + animation: checkmark-appear 0.3s ease-out; +} + +.field-save-indicator__checkmark svg { + width: 16px; + height: 16px; +} + +/* Error Icon */ +.field-save-indicator__error { + display: inline-flex; + color: #dc2626; +} + +.field-save-indicator__error svg { + width: 16px; + height: 16px; +} + +@keyframes checkmark-appear { + 0% { + opacity: 0; + transform: scale(0.5); + } + 50% { + transform: scale(1.2); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +/* Table cell editable fields need special handling for indicator */ +td.editable-field { + position: relative; +} + +td.editable-field .field-save-indicator { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); +} + +/* Card-level save indicator (legacy, hidden by default) */ +.save-indicator { + display: none !important; +} diff --git a/css/components/icons.css b/css/components/icons.css new file mode 100644 index 0000000..ebfa486 --- /dev/null +++ b/css/components/icons.css @@ -0,0 +1,89 @@ +/** + * @file + * Icon styles using CSS mask-image for colorable SVG icons. + * + * This approach allows SVG icons to be styled with CSS color properties + * while keeping the SVG files separate and cacheable. + */ + +/* Base icon styles */ +.cs-icon { + display: inline-block; + width: 16px; + height: 16px; + background-color: currentColor; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; +} + +/* Icon variants */ +.cs-icon--trash { + -webkit-mask-image: url(../../images/icons/trash.svg); + mask-image: url(../../images/icons/trash.svg); +} + +.cs-icon--edit { + -webkit-mask-image: url(../../images/icons/edit.svg); + mask-image: url(../../images/icons/edit.svg); +} + +.cs-icon--checkmark { + -webkit-mask-image: url(../../images/icons/checkmark.svg); + mask-image: url(../../images/icons/checkmark.svg); +} + +.cs-icon--error { + -webkit-mask-image: url(../../images/icons/error.svg); + mask-image: url(../../images/icons/error.svg); +} + +/* Size variants */ +.cs-icon--sm { + width: 14px; + height: 14px; +} + +.cs-icon--md { + width: 18px; + height: 18px; +} + +.cs-icon--lg { + width: 25px; + height: 25px; +} + +/* Delete idea button icon - fills the 25x25 button */ +.delete-idea-link .cs-icon { + width: 20px; + height: 20px; +} + +/* Delete card button icon */ +.delete-card-link .cs-icon { + width: 18px; + height: 18px; +} + +/* Edit link icon */ +.idea-link-edit .cs-icon { + width: 14px; + height: 14px; +} + +/* Save indicator icons */ +.field-save-indicator__checkmark .cs-icon { + width: 16px; + height: 16px; + background-color: #28a745; +} + +.field-save-indicator__error .cs-icon { + width: 16px; + height: 16px; + background-color: #dc2626; +} diff --git a/css/components/links.css b/css/components/links.css new file mode 100644 index 0000000..4ccca71 --- /dev/null +++ b/css/components/links.css @@ -0,0 +1,68 @@ +/** + * @file + * Link styles for AI Content Strategy idea links. + */ + +/* Idea Link Area */ +.idea-link-area { + margin-top: 6px; + font-size: 0.85em; +} + +/* Add link uses Drupal's action-link class */ +.idea-add-link.action-link { + font-size: inherit; +} + +.idea-link { + color: #0073aa; + text-decoration: none; + word-break: break-all; + margin-right: 6px; +} + +.idea-link:hover { + text-decoration: underline; +} + +.idea-link-edit { + background: none; + border: none; + padding: 2px; + color: #6b7280; + cursor: pointer; + vertical-align: middle; + display: inline-flex; + align-items: center; +} + +.idea-link-edit svg { + width: 14px; + height: 14px; +} + +.idea-link-edit:hover { + color: #0073aa; +} + +/* Idea Link Input */ +.idea-link-input-wrapper { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +.idea-link-input { + flex: 1; + padding: 4px 8px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 0.9em; +} + +.idea-link-input:focus { + outline: none; + border-color: #0073aa; + box-shadow: 0 0 0 2px rgba(0, 115, 170, 0.2); +} diff --git a/css/components/table.css b/css/components/table.css new file mode 100644 index 0000000..fb1b30c --- /dev/null +++ b/css/components/table.css @@ -0,0 +1,73 @@ +/** + * @file + * Content ideas table styles for AI Content Strategy. + */ + +/* Content Ideas Table */ +.content-ideas-table { + width: 100%; + margin-top: 1rem; + border-collapse: collapse; +} + +.content-ideas-table thead th { + text-align: left; + padding: 0.5rem; + font-size: 0.9em; + font-weight: 600; + color: #374151; + border-bottom: 2px solid #e5e7eb; +} + +.content-ideas-table thead .idea-actions-header { + width: 40px; + padding: 0; +} + +.content-ideas-table tbody tr { + border-bottom: 1px solid #f3f4f6; + transition: background-color 0.15s ease; +} + +.content-ideas-table tbody tr:hover { + background-color: #f9fafb; +} + +.content-ideas-table tbody td { + padding: 0.75rem 0.5rem; +} + +.content-ideas-table tbody td.idea-actions { + width: 40px; + padding: 0.5rem; + text-align: center; + vertical-align: middle; +} + +.content-ideas-table tbody td.idea-actions > * { + display: block; + margin: 0 auto 4px auto; +} + +.content-ideas-table tbody td.idea-actions > *:last-child { + margin-bottom: 0; +} + +/* Implemented Row Styling */ +.content-ideas-table tbody tr.idea-implemented { + background-color: #f0fdf4; +} + +.content-ideas-table tbody tr.idea-implemented .editable-field { + text-decoration: line-through; + color: #6b7280; +} + +.content-ideas-table tbody tr.idea-implemented:hover { + background-color: #dcfce7; +} + +/* Idea Content Cell */ +.idea-content-cell { + vertical-align: top; +} diff --git a/images/icons/checkmark.svg b/images/icons/checkmark.svg new file mode 100644 index 0000000..1491afa --- /dev/null +++ b/images/icons/checkmark.svg @@ -0,0 +1,3 @@ + diff --git a/images/icons/edit.svg b/images/icons/edit.svg new file mode 100644 index 0000000..54d6980 --- /dev/null +++ b/images/icons/edit.svg @@ -0,0 +1,3 @@ + diff --git a/images/icons/error.svg b/images/icons/error.svg new file mode 100644 index 0000000..15dba57 --- /dev/null +++ b/images/icons/error.svg @@ -0,0 +1,3 @@ + diff --git a/images/icons/trash.svg b/images/icons/trash.svg new file mode 100644 index 0000000..d2c2cbf --- /dev/null +++ b/images/icons/trash.svg @@ -0,0 +1,4 @@ + diff --git a/js/content-ideas.js b/js/content-ideas.js deleted file mode 100644 index 4bdb41c..0000000 --- a/js/content-ideas.js +++ /dev/null @@ -1,398 +0,0 @@ -((Drupal, once) => { - 'use strict'; - - // DOM utility functions - const DOMUtils = { - ensureElementId(element, prefix, index = '') { - if (!element.id) { - element.id = `${prefix}-${index || Date.now()}`; - } - return element.id; - }, - - getItemData(element) { - const item = element.closest('.recommendation-item'); - if (!item) return {}; - return { - section: item.dataset.section, - title: item.dataset.title - }; - }, - - safeInsertHTML(target, html, method = 'html') { - if (!target) return false; - if (method === 'html') { - target.innerHTML = html; - } else if (method === 'append') { - target.insertAdjacentHTML('beforeend', html); - } - return true; - }, - - getButtonText(settings, type, section) { - if (!settings?.aiContentStrategy?.buttonText) { - throw new Error('Button text settings are not available. Make sure aiContentStrategy.buttonText is properly initialized in drupalSettings.'); - } - - const buttonTexts = settings.aiContentStrategy.buttonText; - if (!buttonTexts[type]) { - throw new Error(`Button text type "${type}" is not defined in settings.`); - } - if (!buttonTexts[type][section] && type !== 'main') { - throw new Error(`Button text for section "${section}" is not defined in type "${type}".`); - } - - return buttonTexts[type][section]; - } - }; - - // AJAX handler factory - function createAjaxHandler({ - element, - url, - loadingText, - successText, - errorText, - onSuccess, - method = 'html' - }, drupalSettings) { - if (!drupalSettings?.aiContentStrategy?.buttonText) { - throw new Error('Button text settings are not available. Make sure aiContentStrategy.buttonText is properly initialized in drupalSettings.'); - } - - if (!loadingText || !successText || !errorText) { - throw new Error('Required button texts are missing. Make sure all text parameters are provided.'); - } - - DOMUtils.ensureElementId(element, 'content-strategy'); - - const ajaxSettings = { - base: element.id, - element: element, - url: drupalSettings.path.baseUrl + url, - submit: { js: true }, - progress: { type: 'throbber' }, - beforeSend: function(xhr, settings) { - element.textContent = loadingText; - element.disabled = true; - Drupal.announce(loadingText, 'polite'); - return true; - }, - success: function(response, status) { - let hasError = false; - - if (Array.isArray(response)) { - // Process all commands first - response.forEach((command) => { - if (command.command === 'insert') { - const target = document.querySelector(command.selector); - - // Special handling for last-run-time - if (command.selector === '.last-run-time') { - let lastRunTime = target; - if (!lastRunTime) { - lastRunTime = document.createElement('div'); - lastRunTime.className = 'last-run-time'; - const actionsContainer = document.querySelector('.content-strategy-actions'); - if (actionsContainer) { - actionsContainer.appendChild(lastRunTime); - } - } - if (lastRunTime) { - DOMUtils.safeInsertHTML(lastRunTime, command.data, command.method); - } - } else if (target) { - // If this is the main recommendations wrapper, remove the old one first - if (command.selector === '.content-strategy-recommendations' && command.method === 'append') { - const oldWrapper = target.querySelector('.recommendations-wrapper'); - if (oldWrapper) { - oldWrapper.remove(); - } - } - DOMUtils.safeInsertHTML(target, command.data, command.method); - } - } - // Handle message commands using Drupal.Message API - else if (command.command === 'message') { - const messages = new Drupal.Message(); - - if (command.clearPrevious) { - messages.clear(); - } - - messages.add(command.message, { - type: command.messageOptions?.type || 'status', - id: `content-strategy-message-${Date.now()}`, - announce: command.message - }); - - // Track if this is an error message - if (command.messageOptions?.type === 'error') { - hasError = true; - } - } - else if (command.command === 'remove') { - const target = document.querySelector(command.selector); - if (target) { - target.remove(); - } - } - }); - - // After all commands are processed, call onSuccess if provided and no errors - if (onSuccess && !hasError) { - const mainContainer = document.querySelector('.content-strategy-recommendations'); - onSuccess(mainContainer); - } - } - - element.disabled = false; - - // Update button text and announcement based on whether there was an error - if (hasError) { - element.textContent = errorText; - } else { - element.textContent = successText; - Drupal.announce(Drupal.t('Content loaded successfully'), 'polite'); - } - }, - error: function(xhr, status, error) { - element.disabled = false; - element.textContent = errorText; - - try { - const response = JSON.parse(xhr.responseText); - const messages = new Drupal.Message(); - - if (response[0]?.message) { - messages.add(response[0].message, { - type: 'error', - id: `content-strategy-error-${Date.now()}`, - announce: response[0].message - }); - } else { - messages.add(Drupal.t('An error occurred while processing your request.'), { - type: 'error', - id: `content-strategy-error-${Date.now()}` - }); - } - } catch (e) { - const messages = new Drupal.Message(); - messages.add(Drupal.t('An error occurred while processing your request.'), { - type: 'error', - id: `content-strategy-error-${Date.now()}` - }); - } - } - }; - - return ajaxSettings; - } - - // Attach generate ideas behavior (initial generation for items without content_ideas) - function attachGenerateIdeasBehavior(link, index, settings) { - const { section, title } = DOMUtils.getItemData(link); - if (!section || !title) { - return; - } - - const buttonText = DOMUtils.getButtonText(settings, 'generate', section); - if (buttonText) { - link.textContent = buttonText; - } - - try { - DOMUtils.ensureElementId(link, 'content-strategy'); - const ajaxHandler = new Drupal.Ajax( - link.id, - link, - createAjaxHandler({ - element: link, - url: `admin/reports/ai/content-strategy/generate-more/${section}/${encodeURIComponent(title)}`, - loadingText: settings?.aiContentStrategy?.buttonText?.main?.loading, - successText: buttonText || link.textContent, - errorText: buttonText || link.textContent, - method: 'append', - onSuccess: (target) => { - // After generating initial ideas, replace the button with "generate more" link - const item = link.closest('.recommendation-item'); - if (item) { - const actionsDiv = item.querySelector('.recommendation-actions'); - if (actionsDiv) { - // Remove the generate button - link.remove(); - - // Create the "generate more" link - const moreLink = document.createElement('a'); - moreLink.href = '#'; - moreLink.className = 'generate-more-link'; - moreLink.dataset.section = section; - moreLink.dataset.title = title; - moreLink.textContent = DOMUtils.getButtonText(settings, 'generate_more', section); - actionsDiv.appendChild(moreLink); - - // Attach behavior to the new link - attachGenerateMoreBehavior(moreLink, 0, settings); - } - } - } - }, settings) - ); - - link.addEventListener('click', function(event) { - event.preventDefault(); - ajaxHandler.execute(); - }); - } catch (e) { - // Error handling without console.error - } - } - - // Attach generate more behavior - function attachGenerateMoreBehavior(link, index, settings) { - const { section, title } = DOMUtils.getItemData(link); - if (!section || !title) { - return; - } - - const buttonText = DOMUtils.getButtonText(settings, 'generate_more', section); - if (buttonText) { - link.textContent = buttonText; - } - - try { - DOMUtils.ensureElementId(link, 'content-strategy'); - const ajaxHandler = new Drupal.Ajax( - link.id, - link, - createAjaxHandler({ - element: link, - url: `admin/reports/ai/content-strategy/generate-more/${section}/${encodeURIComponent(title)}`, - loadingText: settings?.aiContentStrategy?.buttonText?.main?.loading, - successText: buttonText || link.textContent, - errorText: buttonText || link.textContent, - method: 'append' - }, settings) - ); - - link.addEventListener('click', function(event) { - event.preventDefault(); - ajaxHandler.execute(); - }); - } catch (e) { - // Error handling without console.error - } - } - - // Attach add more recommendations behavior - function attachAddMoreRecommendationsBehavior(link, settings) { - const section = link.dataset.section; - if (!section) { - return; - } - - // Ensure the link has an ID for Drupal.Ajax - DOMUtils.ensureElementId(link, 'content-strategy'); - - const buttonText = DOMUtils.getButtonText(settings, 'add_more', section); - if (buttonText) { - link.textContent = buttonText; - } - - try { - const ajaxHandler = new Drupal.Ajax( - link.id, - link, - createAjaxHandler({ - element: link, - url: `admin/reports/ai/content-strategy/add-more/${section}`, - loadingText: settings?.aiContentStrategy?.buttonText?.main?.loading_more, - successText: buttonText || link.textContent, - errorText: buttonText || link.textContent, - method: 'append', - onSuccess: (target) => { - // Find and attach behaviors to any new generate more links - target.querySelectorAll('.generate-more-link').forEach((newLink, index) => { - attachGenerateMoreBehavior(newLink, index, settings); - }); - } - }, settings) - ); - - link.addEventListener('click', function(event) { - event.preventDefault(); - ajaxHandler.execute(); - }); - } catch (e) { - // Error handling without console.error - } - } - - // Main behavior - Drupal.behaviors.contentIdeas = { - attach: function (context, settings) { - // Handle main generate button - once('contentIdeas', '.generate-recommendations', context).forEach((button) => { - // Don't override the initial button text from server - const initialText = button.textContent.trim(); - const hasRecommendations = initialText === settings?.aiContentStrategy?.buttonText?.main?.refresh; - - try { - DOMUtils.ensureElementId(button, 'content-strategy'); - const ajaxHandler = new Drupal.Ajax( - button.id, - button, - createAjaxHandler({ - element: button, - url: 'admin/reports/ai/content-strategy/generate', - loadingText: settings?.aiContentStrategy?.buttonText?.main?.loading, - successText: settings?.aiContentStrategy?.buttonText?.main?.refresh, - errorText: hasRecommendations ? settings?.aiContentStrategy?.buttonText?.main?.refresh : settings?.aiContentStrategy?.buttonText?.main?.generate, - onSuccess: (target) => { - // Find the recommendations wrapper that was just added - const wrapper = document.querySelector('.recommendations-wrapper'); - if (wrapper) { - // Reattach behaviors to all generate ideas links - wrapper.querySelectorAll('.generate-ideas-link').forEach((link, index) => { - attachGenerateIdeasBehavior(link, index, settings); - }); - // Reattach behaviors to all generate more links - wrapper.querySelectorAll('.generate-more-link').forEach((link, index) => { - attachGenerateMoreBehavior(link, index, settings); - }); - // Reattach behaviors to all add more recommendations links - wrapper.querySelectorAll('.add-more-recommendations-link').forEach((link) => { - attachAddMoreRecommendationsBehavior(link, settings); - }); - } - } - }, settings) - ); - - button.addEventListener('click', function(event) { - event.preventDefault(); - ajaxHandler.execute(); - }); - } catch (e) { - // Error handling without console.error - } - }); - - // Handle generate ideas links (initial generation) - once('generate-ideas', '.generate-ideas-link', context).forEach((link, index) => { - attachGenerateIdeasBehavior(link, index, settings); - }); - - // Handle generate more links - once('content-ideas', '.generate-more-link', context).forEach((link, index) => { - attachGenerateMoreBehavior(link, index, settings); - }); - - // Handle add more recommendations links - once('content-ideas', '.add-more-recommendations-link', context).forEach((link) => { - attachAddMoreRecommendationsBehavior(link, settings); - }); - } - }; - -})(Drupal, once); \ No newline at end of file diff --git a/js/content-strategy-checkbox.js b/js/content-strategy-checkbox.js new file mode 100644 index 0000000..21e6b3c --- /dev/null +++ b/js/content-strategy-checkbox.js @@ -0,0 +1,91 @@ +/** + * @file + * Implemented checkbox behavior for AI Content Strategy recommendations. + * + * Uses Drupal's AJAX framework for proper command processing and behavior + * attachment. + */ + +((Drupal, once) => { + 'use strict'; + + /** + * Implemented checkbox toggle behavior. + */ + Drupal.behaviors.contentStrategyCheckbox = { + attach: function(context, settings) { + once('contentStrategyCheckbox', '.idea-implemented-checkbox', context).forEach((checkbox) => { + checkbox.addEventListener('change', function(event) { + const section = checkbox.dataset.section; + const uuid = checkbox.dataset.uuid; + const ideaUuid = checkbox.dataset.ideaUuid; + const isImplemented = checkbox.checked; + + // Optimistic UI update - apply changes immediately. + const row = checkbox.closest('tr'); + if (isImplemented) { + row.classList.add('idea-implemented'); + } + else { + row.classList.remove('idea-implemented'); + } + + // Show/hide link area based on implemented status. + const linkArea = row.querySelector('.idea-link-area'); + if (linkArea) { + linkArea.style.display = isImplemented ? '' : 'none'; + } + + // Disable checkbox during request. + checkbox.disabled = true; + + // Ensure element has an ID for Drupal.ajax. + if (!checkbox.id) { + checkbox.id = 'checkbox-' + Date.now(); + } + + // Use Drupal's AJAX framework. + const ajaxObject = Drupal.ajax({ + url: Drupal.url('admin/reports/ai/content-strategy/save-card/' + section + '/' + uuid), + base: checkbox.id, + element: checkbox, + submit: { + field: 'implemented', + value: isImplemented ? '1' : '0', + idea_uuid: ideaUuid + }, + progress: { type: 'none' }, + success: function(response, status) { + // Re-enable checkbox. + checkbox.disabled = false; + }, + error: function(xhr, status, error) { + // Revert on error. + checkbox.checked = !isImplemented; + checkbox.disabled = false; + + if (!isImplemented) { + row.classList.add('idea-implemented'); + } + else { + row.classList.remove('idea-implemented'); + } + if (linkArea) { + linkArea.style.display = !isImplemented ? '' : 'none'; + } + + const messages = new Drupal.Message(); + messages.add(Drupal.t('Error saving implementation status.'), { + type: 'error', + id: 'content-strategy-error-' + Date.now() + }); + } + }); + + ajaxObject.execute(); + }); + }); + } + }; + +})(Drupal, once); diff --git a/js/content-strategy-delete.js b/js/content-strategy-delete.js new file mode 100644 index 0000000..da9d1ff --- /dev/null +++ b/js/content-strategy-delete.js @@ -0,0 +1,137 @@ +/** + * @file + * Delete handlers for AI Content Strategy recommendations. + * + * Uses Drupal's AJAX framework for proper command processing and behavior + * attachment. + */ + +((Drupal, once) => { + 'use strict'; + + /** + * Delete card behavior. + */ + Drupal.behaviors.contentStrategyDeleteCard = { + attach: function(context, settings) { + once('contentStrategyDeleteCard', '.delete-card-link', context).forEach((link) => { + link.addEventListener('click', function(event) { + event.preventDefault(); + + const section = link.dataset.section; + const uuid = link.dataset.uuid; + + const card = link.closest('.recommendation-item'); + const cardTitle = card.querySelector('h4')?.textContent || 'this recommendation'; + const ideasTable = card.querySelector('.content-ideas-table tbody'); + const ideasCount = ideasTable ? ideasTable.querySelectorAll('tr').length : 0; + + // Build contextual confirmation message. + let confirmMessage = Drupal.t('Delete "@title"?', {'@title': cardTitle}); + if (ideasCount > 0) { + confirmMessage += '\n\n' + Drupal.t('This will permanently delete @count content idea(s).', {'@count': ideasCount}); + } + else { + confirmMessage += '\n\n' + Drupal.t('This recommendation has no content ideas yet.'); + } + confirmMessage += '\n\n' + Drupal.t('This action cannot be undone.'); + + if (!confirm(confirmMessage)) { + return; + } + + // Disable link during request. + link.style.opacity = '0.5'; + link.style.pointerEvents = 'none'; + + // Ensure element has an ID for Drupal.ajax. + if (!link.id) { + link.id = 'delete-card-' + Date.now(); + } + + // Use Drupal's AJAX framework - automatically processes commands + // and attaches behaviors. + const ajaxObject = Drupal.ajax({ + url: Drupal.url('admin/reports/ai/content-strategy/delete-card/' + section + '/' + uuid), + base: link.id, + element: link, + progress: { type: 'none' }, + error: function(xhr, status, error) { + const messages = new Drupal.Message(); + messages.add(Drupal.t('An error occurred while deleting.'), { + type: 'error', + id: 'content-strategy-error-' + Date.now() + }); + link.style.opacity = '1'; + link.style.pointerEvents = 'auto'; + } + }); + + ajaxObject.execute(); + }); + }); + } + }; + + /** + * Delete idea behavior. + */ + Drupal.behaviors.contentStrategyDeleteIdea = { + attach: function(context, settings) { + once('contentStrategyDeleteIdea', '.delete-idea-link', context).forEach((button) => { + button.addEventListener('click', function(event) { + event.preventDefault(); + + const section = button.dataset.section; + const uuid = button.dataset.uuid; + const ideaUuid = button.dataset.ideaUuid; + + const row = button.closest('tr'); + const ideaCell = row.querySelector('.editable-field'); + const ideaText = ideaCell ? ideaCell.textContent.trim() : ''; + + // Build contextual confirmation message. + let confirmMessage = Drupal.t('Delete this content idea?'); + if (ideaText) { + const truncatedText = ideaText.length > 60 ? ideaText.substring(0, 60) + '...' : ideaText; + confirmMessage += '\n\n"' + truncatedText + '"'; + } + confirmMessage += '\n\n' + Drupal.t('This action cannot be undone.'); + + if (!confirm(confirmMessage)) { + return; + } + + // Disable button during request. + button.style.opacity = '0.5'; + button.style.pointerEvents = 'none'; + + // Ensure element has an ID for Drupal.ajax. + if (!button.id) { + button.id = 'delete-idea-' + Date.now(); + } + + // Use Drupal's AJAX framework. + const ajaxObject = Drupal.ajax({ + url: Drupal.url('admin/reports/ai/content-strategy/delete-idea/' + section + '/' + uuid + '/' + ideaUuid), + base: button.id, + element: button, + progress: { type: 'none' }, + error: function(xhr, status, error) { + const messages = new Drupal.Message(); + messages.add(Drupal.t('An error occurred while deleting the content idea.'), { + type: 'error', + id: 'content-strategy-error-' + Date.now() + }); + button.style.opacity = '1'; + button.style.pointerEvents = 'auto'; + } + }); + + ajaxObject.execute(); + }); + }); + } + }; + +})(Drupal, once); diff --git a/js/content-strategy-editable.js b/js/content-strategy-editable.js new file mode 100644 index 0000000..4a05b09 --- /dev/null +++ b/js/content-strategy-editable.js @@ -0,0 +1,157 @@ +/** + * @file + * Editable field behaviors for AI Content Strategy recommendations. + * + * Uses Drupal's AJAX framework for proper command processing and behavior + * attachment. + */ + +((Drupal, once) => { + 'use strict'; + + /** + * Editable fields behavior with auto-save and visual feedback. + */ + Drupal.behaviors.contentStrategyEditable = { + attach: function(context, settings) { + once('contentStrategyEditable', '.editable-field', context).forEach((field) => { + let saveTimeout; + const card = field.closest('.recommendation-item'); + const section = card.dataset.section; + const uuid = card.dataset.uuid; + + // CSS icon markup (uses mask-image for color control). + const checkmarkHTML = ''; + const errorHTML = ''; + + /** + * Creates or retrieves the field-specific save indicator. + * + * @returns {HTMLElement} The indicator element. + */ + const getOrCreateIndicator = () => { + // Check inside field first (for TD elements). + let indicator = field.querySelector('.field-save-indicator'); + // Also check next sibling (for non-TD elements where indicator is placed after). + if (!indicator && field.nextElementSibling?.classList.contains('field-save-indicator')) { + indicator = field.nextElementSibling; + } + if (!indicator) { + indicator = document.createElement('span'); + indicator.className = 'field-save-indicator'; + if (field.tagName === 'TD') { + field.appendChild(indicator); + } + else { + field.insertAdjacentElement('afterend', indicator); + } + } + return indicator; + }; + + /** + * Shows saving state with Drupal's throbber spinner. + */ + const showSaving = () => { + field.classList.add('editable-field--saving'); + field.classList.remove('editable-field--saved'); + const indicator = getOrCreateIndicator(); + indicator.innerHTML = Drupal.theme.ajaxProgressThrobber(); + }; + + /** + * Shows saved state with checkmark and green flash. + */ + const showSaved = () => { + field.classList.remove('editable-field--saving'); + field.classList.add('editable-field--saved'); + const indicator = getOrCreateIndicator(); + indicator.innerHTML = checkmarkHTML; + + setTimeout(() => { + field.classList.remove('editable-field--saved'); + indicator.remove(); + }, 2000); + }; + + /** + * Shows error state. + */ + const showError = () => { + field.classList.remove('editable-field--saving'); + const indicator = getOrCreateIndicator(); + indicator.innerHTML = errorHTML; + + setTimeout(() => { + indicator.remove(); + }, 3000); + }; + + /** + * Saves the field content to the server using Drupal AJAX. + */ + const saveEdit = () => { + const fieldName = field.dataset.field; + const value = field.textContent.trim(); + const ideaUuid = field.dataset.ideaUuid || null; + + showSaving(); + + // Ensure element has an ID for Drupal.ajax. + if (!field.id) { + field.id = 'editable-' + Date.now(); + } + + // Build submit data. + const submitData = { + field: fieldName, + value: value + }; + if (ideaUuid !== null) { + submitData.idea_uuid = ideaUuid; + } + + // Use Drupal's AJAX framework. + const ajaxObject = Drupal.ajax({ + url: Drupal.url('admin/reports/ai/content-strategy/save-card/' + section + '/' + uuid), + base: field.id, + element: field, + submit: submitData, + progress: { type: 'none' }, + success: function(response, status) { + showSaved(); + }, + error: function(xhr, status, error) { + showError(); + } + }); + + ajaxObject.execute(); + }; + + // Debounced save on input. + field.addEventListener('input', () => { + clearTimeout(saveTimeout); + saveTimeout = setTimeout(saveEdit, 1000); + }); + + // Save on blur. + field.addEventListener('blur', () => { + clearTimeout(saveTimeout); + saveEdit(); + }); + + // Prevent Enter key creating new lines in title. + if (field.dataset.field === 'title') { + field.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + field.blur(); + } + }); + } + }); + } + }; + +})(Drupal, once); diff --git a/js/content-strategy-export.js b/js/content-strategy-export.js new file mode 100644 index 0000000..db13f1f --- /dev/null +++ b/js/content-strategy-export.js @@ -0,0 +1,98 @@ +/** + * @file + * CSV export functionality for AI Content Strategy recommendations. + */ + +((Drupal, once) => { + 'use strict'; + + /** + * CSV export behavior. + */ + Drupal.behaviors.contentStrategyExport = { + attach: function(context, settings) { + once('contentStrategyExport', '.export-csv-button', context).forEach((button) => { + button.addEventListener('click', function(event) { + event.preventDefault(); + + const csvData = []; + + // Header row + csvData.push(['Content Idea', 'Implemented', 'Link', 'Category', 'Priority', 'Recommendation Title']); + + // Loop through all recommendation cards + document.querySelectorAll('.recommendation-item').forEach((card) => { + const section = card.dataset.section || ''; + const title = card.querySelector('h4')?.textContent?.trim() || ''; + const priority = card.querySelector('.priority-badge')?.textContent?.trim() || ''; + + // Get category name from section heading + const sectionElement = card.closest('.recommendation-section'); + const categoryName = sectionElement?.querySelector('h3')?.textContent?.trim() || section; + + // Get all content ideas + const ideas = card.querySelectorAll('.content-ideas-table tbody tr'); + + if (ideas.length > 0) { + ideas.forEach((ideaRow) => { + const idea = ideaRow.querySelector('.editable-field')?.textContent?.trim() || ''; + const isImplemented = ideaRow.classList.contains('idea-implemented') ? 'Yes' : 'No'; + const link = ideaRow.querySelector('.idea-link')?.href || ''; + csvData.push([ + idea, + isImplemented, + link, + categoryName, + priority, + title + ]); + }); + } else { + // Card without ideas + csvData.push([ + '', + '', + '', + categoryName, + priority, + title + ]); + } + }); + + // Convert to CSV string + const csvContent = csvData.map(row => + row.map(cell => { + const cellStr = String(cell).replace(/"/g, '""'); + if (cellStr.includes(',') || cellStr.includes('\n') || cellStr.includes('"')) { + return `"${cellStr}"`; + } + return cellStr; + }).join(',') + ).join('\n'); + + // Create blob and trigger download + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + const timestamp = new Date().toISOString().split('T')[0]; + + link.setAttribute('href', url); + link.setAttribute('download', `content-strategy-${timestamp}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Show success message + const messages = new Drupal.Message(); + messages.add(Drupal.t('CSV file downloaded successfully.'), { + type: 'status', + id: `csv-export-success-${Date.now()}` + }); + }); + }); + } + }; + +})(Drupal, once); diff --git a/js/content-strategy-generate.js b/js/content-strategy-generate.js new file mode 100644 index 0000000..b525101 --- /dev/null +++ b/js/content-strategy-generate.js @@ -0,0 +1,267 @@ +/** + * @file + * Generate behaviors for AI Content Strategy recommendations. + * + * Uses Drupal's AJAX framework for proper command processing and behavior + * attachment. + */ + +((Drupal, once) => { + 'use strict'; + + const { DOMUtils, createAjaxHandler } = Drupal.aiContentStrategy; + + /** + * Attaches generate ideas behavior (initial generation). + * + * @param {HTMLElement} link - The generate ideas link. + * @param {number} index - Link index. + * @param {Object} settings - drupalSettings object. + */ + function attachGenerateIdeasBehavior(link, index, settings) { + const { section, uuid } = DOMUtils.getItemData(link); + if (!section || !uuid) { + return; + } + + const buttonText = DOMUtils.getButtonText(settings, 'generate', section); + if (buttonText) { + link.textContent = buttonText; + } + + try { + DOMUtils.ensureElementId(link, 'content-strategy'); + const ajaxHandler = new Drupal.Ajax( + link.id, + link, + createAjaxHandler({ + element: link, + url: `admin/reports/ai/content-strategy/generate-more/${section}/${uuid}`, + loadingText: settings?.aiContentStrategy?.buttonText?.main?.loading, + successText: buttonText || link.textContent, + errorText: buttonText || link.textContent, + onSuccess: (target) => { + const item = link.closest('.recommendation-item'); + if (item) { + const actionsDiv = item.querySelector('.recommendation-actions'); + if (actionsDiv) { + link.remove(); + + const moreLink = document.createElement('a'); + moreLink.href = '#'; + moreLink.className = 'generate-more-link'; + moreLink.dataset.section = section; + moreLink.dataset.uuid = uuid; + moreLink.textContent = DOMUtils.getButtonText(settings, 'generate_more', section); + actionsDiv.appendChild(moreLink); + + attachGenerateMoreBehavior(moreLink, 0, settings); + } + } + } + }, settings) + ); + + link.addEventListener('click', function(event) { + event.preventDefault(); + ajaxHandler.execute(); + }); + } + catch (e) { + // Error handling without console.error. + } + } + + /** + * Attaches generate more behavior. + * + * @param {HTMLElement} link - The generate more link. + * @param {number} index - Link index. + * @param {Object} settings - drupalSettings object. + */ + function attachGenerateMoreBehavior(link, index, settings) { + // Get uuid from link's data attribute or from parent item. + let section = link.dataset.section; + let uuid = link.dataset.uuid; + + // Fallback to parent item data if not on link. + if (!uuid) { + const itemData = DOMUtils.getItemData(link); + section = section || itemData.section; + uuid = itemData.uuid; + } + + if (!section || !uuid) { + return; + } + + const buttonText = DOMUtils.getButtonText(settings, 'generate_more', section); + if (buttonText) { + link.textContent = buttonText; + } + + try { + DOMUtils.ensureElementId(link, 'content-strategy'); + const ajaxHandler = new Drupal.Ajax( + link.id, + link, + createAjaxHandler({ + element: link, + url: `admin/reports/ai/content-strategy/generate-more/${section}/${uuid}`, + loadingText: settings?.aiContentStrategy?.buttonText?.main?.loading, + successText: buttonText || link.textContent, + errorText: buttonText || link.textContent + }, settings) + ); + + link.addEventListener('click', function(event) { + event.preventDefault(); + ajaxHandler.execute(); + }); + } + catch (e) { + // Error handling without console.error. + } + } + + /** + * Attaches add more recommendations behavior. + * + * @param {HTMLElement} link - The add more link. + * @param {Object} settings - drupalSettings object. + */ + function attachAddMoreRecommendationsBehavior(link, settings) { + const section = link.dataset.section; + if (!section) { + return; + } + + DOMUtils.ensureElementId(link, 'content-strategy'); + + const buttonText = DOMUtils.getButtonText(settings, 'add_more', section); + if (buttonText) { + link.textContent = buttonText; + } + + try { + const ajaxHandler = new Drupal.Ajax( + link.id, + link, + createAjaxHandler({ + element: link, + url: `admin/reports/ai/content-strategy/add-more/${section}`, + loadingText: settings?.aiContentStrategy?.buttonText?.main?.loading_more, + successText: buttonText || link.textContent, + errorText: buttonText || link.textContent, + onSuccess: (target) => { + target.querySelectorAll('.generate-more-link').forEach((newLink, index) => { + attachGenerateMoreBehavior(newLink, index, settings); + }); + } + }, settings) + ); + + link.addEventListener('click', function(event) { + event.preventDefault(); + ajaxHandler.execute(); + }); + } + catch (e) { + // Error handling without console.error. + } + } + + // Export functions to namespace for cross-module access. + Drupal.aiContentStrategy.attachGenerateIdeasBehavior = attachGenerateIdeasBehavior; + Drupal.aiContentStrategy.attachGenerateMoreBehavior = attachGenerateMoreBehavior; + Drupal.aiContentStrategy.attachAddMoreRecommendationsBehavior = attachAddMoreRecommendationsBehavior; + + /** + * Main generate button behavior. + */ + Drupal.behaviors.contentStrategyGenerate = { + attach: function(context, settings) { + // Handle main generate button. + once('contentStrategyGenerate', '.generate-recommendations', context).forEach((button) => { + try { + DOMUtils.ensureElementId(button, 'content-strategy'); + const ajaxHandler = new Drupal.Ajax( + button.id, + button, + createAjaxHandler({ + element: button, + url: 'admin/reports/ai/content-strategy/generate', + loadingText: settings?.aiContentStrategy?.buttonText?.main?.loading, + successText: button.textContent.trim(), + errorText: button.textContent.trim(), + onSuccess: (target) => { + const wrapper = document.querySelector('.recommendations-wrapper'); + if (wrapper) { + wrapper.querySelectorAll('.generate-ideas-link').forEach((link, index) => { + attachGenerateIdeasBehavior(link, index, settings); + }); + wrapper.querySelectorAll('.generate-more-link').forEach((link, index) => { + attachGenerateMoreBehavior(link, index, settings); + }); + wrapper.querySelectorAll('.add-more-recommendations-link').forEach((link) => { + attachAddMoreRecommendationsBehavior(link, settings); + }); + } + // Attach behaviors to the newly added export button. + const actionsArea = document.querySelector('.content-strategy-actions'); + if (actionsArea) { + Drupal.attachBehaviors(actionsArea, settings); + } + } + }, settings) + ); + + button.addEventListener('click', function(event) { + event.preventDefault(); + + if (button.dataset.hasExisting === 'true') { + const confirmed = confirm( + Drupal.t('Regenerate all recommendations?\n\n') + + Drupal.t('All existing recommendations will be replaced. This cannot be undone.') + ); + if (!confirmed) { + return; + } + } + + const messages = new Drupal.Message(); + messages.add( + Drupal.t('Analyzing your site and generating recommendations... This may take a minute.'), + { + type: 'status', + id: `generation-progress-${Date.now()}`, + announce: Drupal.t('Generating recommendations, please wait') + } + ); + + ajaxHandler.execute(); + }); + } + catch (e) { + // Error handling without console.error. + } + }); + + // Handle generate ideas links. + once('generateIdeas', '.generate-ideas-link', context).forEach((link, index) => { + attachGenerateIdeasBehavior(link, index, settings); + }); + + // Handle generate more links. + once('generateMore', '.generate-more-link', context).forEach((link, index) => { + attachGenerateMoreBehavior(link, index, settings); + }); + + // Handle add more recommendations links. + once('addMoreRecs', '.add-more-recommendations-link', context).forEach((link) => { + attachAddMoreRecommendationsBehavior(link, settings); + }); + } + }; + +})(Drupal, once); diff --git a/js/content-strategy-links.js b/js/content-strategy-links.js new file mode 100644 index 0000000..ae3a14c --- /dev/null +++ b/js/content-strategy-links.js @@ -0,0 +1,149 @@ +/** + * @file + * Link handlers for AI Content Strategy idea links. + * + * Uses Drupal's AJAX framework for proper command processing and behavior + * attachment. + */ + +((Drupal, once) => { + 'use strict'; + + /** + * Shows the link input form. + * + * @param {HTMLElement} linkArea - The link area element. + * @param {string} section - Section identifier. + * @param {string} uuid - Card UUID. + * @param {string} ideaUuid - Idea UUID. + * @param {string} currentLink - Current link value. + * @param {Object} settings - drupalSettings object. + */ + function showLinkInput(linkArea, section, uuid, ideaUuid, currentLink, settings) { + const originalContent = linkArea.innerHTML; + const translations = settings.aiContentStrategy?.translations || {}; + + // Generate link input HTML (this stays client-side as it's temporary UI). + const linkInputHTML = ` + + `; + + linkArea.innerHTML = linkInputHTML; + + const input = linkArea.querySelector('.idea-link-input'); + const saveBtn = linkArea.querySelector('.idea-link-save'); + const cancelBtn = linkArea.querySelector('.idea-link-cancel'); + + input.focus(); + + saveBtn.addEventListener('click', () => { + const link = input.value.trim(); + + // Disable buttons during request. + saveBtn.disabled = true; + cancelBtn.disabled = true; + saveBtn.textContent = Drupal.t('Saving...'); + + // Ensure we have an ID for Drupal.ajax. + if (!saveBtn.id) { + saveBtn.id = 'save-link-' + Date.now(); + } + + // Use Drupal's AJAX framework. + const ajaxObject = Drupal.ajax({ + url: Drupal.url('admin/reports/ai/content-strategy/save-card/' + section + '/' + uuid), + base: saveBtn.id, + element: saveBtn, + submit: { + field: 'link', + value: link, + idea_uuid: ideaUuid + }, + progress: { type: 'none' }, + error: function(xhr, status, error) { + const messages = new Drupal.Message(); + messages.add(Drupal.t('Error saving link.'), { + type: 'error', + id: 'content-strategy-error-' + Date.now() + }); + linkArea.innerHTML = originalContent; + Drupal.attachBehaviors(linkArea); + } + }); + + // Override the success method to attach behaviors after commands run. + // Drupal's AJAX success handler processes commands, then calls this. + const originalSuccess = ajaxObject.success; + ajaxObject.success = function(response, status) { + // Call original success to process AJAX commands (including HtmlCommand). + originalSuccess.call(this, response, status); + // Now attach behaviors to the updated linkArea. + // Use setTimeout to ensure DOM is updated after HtmlCommand. + setTimeout(() => { + Drupal.attachBehaviors(linkArea); + }, 0); + }; + + ajaxObject.execute(); + }); + + cancelBtn.addEventListener('click', () => { + linkArea.innerHTML = originalContent; + Drupal.attachBehaviors(linkArea); + }); + + // Keyboard shortcuts. + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveBtn.click(); + } else if (e.key === 'Escape') { + cancelBtn.click(); + } + }); + } + + /** + * Add link button behavior. + */ + Drupal.behaviors.contentStrategyAddLink = { + attach: function(context, settings) { + once('contentStrategyAddLink', '.idea-add-link', context).forEach((button) => { + button.addEventListener('click', function(event) { + event.preventDefault(); + const section = button.dataset.section; + const uuid = button.dataset.uuid; + const ideaUuid = button.dataset.ideaUuid; + const linkArea = button.closest('.idea-link-area'); + + showLinkInput(linkArea, section, uuid, ideaUuid, '', settings); + }); + }); + } + }; + + /** + * Edit link button behavior. + */ + Drupal.behaviors.contentStrategyEditLink = { + attach: function(context, settings) { + once('contentStrategyEditLink', '.idea-link-edit', context).forEach((button) => { + button.addEventListener('click', function(event) { + event.preventDefault(); + const section = button.dataset.section; + const uuid = button.dataset.uuid; + const ideaUuid = button.dataset.ideaUuid; + const linkArea = button.closest('.idea-link-area'); + const currentLink = linkArea.querySelector('.idea-link')?.href || ''; + + showLinkInput(linkArea, section, uuid, ideaUuid, currentLink, settings); + }); + }); + } + }; + +})(Drupal, once); diff --git a/js/content-strategy-utils.js b/js/content-strategy-utils.js new file mode 100644 index 0000000..d39bcec --- /dev/null +++ b/js/content-strategy-utils.js @@ -0,0 +1,204 @@ +/** + * @file + * Shared utilities for AI Content Strategy module. + */ + +((Drupal) => { + 'use strict'; + + // Create namespace for content strategy utilities. + Drupal.aiContentStrategy = Drupal.aiContentStrategy || {}; + + /** + * DOM utility functions. + */ + Drupal.aiContentStrategy.DOMUtils = { + /** + * Ensures an element has an ID, generating one if needed. + * + * @param {HTMLElement} element - The element to check. + * @param {string} prefix - Prefix for generated ID. + * @param {string|number} index - Optional index for uniqueness. + * @returns {string} The element's ID. + */ + ensureElementId(element, prefix, index = '') { + if (!element.id) { + element.id = `${prefix}-${index || Date.now()}`; + } + return element.id; + }, + + /** + * Gets section and uuid data from a recommendation item. + * + * @param {HTMLElement} element - Element within a recommendation item. + * @returns {Object} Object with section and uuid properties. + */ + getItemData(element) { + const item = element.closest('.recommendation-item'); + if (!item) { + return {}; + } + return { + section: item.dataset.section, + uuid: item.dataset.uuid + }; + }, + + /** + * Gets translated button text from drupalSettings. + * + * @param {Object} settings - drupalSettings object. + * @param {string} type - Button text type. + * @param {string} section - Section identifier. + * @returns {string} Translated button text. + */ + getButtonText(settings, type, section) { + if (!settings?.aiContentStrategy?.buttonText) { + throw new Error('Button text settings are not available.'); + } + + const buttonTexts = settings.aiContentStrategy.buttonText; + if (!buttonTexts[type]) { + throw new Error(`Button text type "${type}" is not defined.`); + } + if (!buttonTexts[type][section] && type !== 'main') { + throw new Error(`Button text for section "${section}" not defined.`); + } + + return buttonTexts[type][section]; + }, + + /** + * Clears progress messages containing specific text. + */ + clearProgressMessages() { + document.querySelectorAll('[data-drupal-message-type="status"]').forEach((msg) => { + if (msg.textContent.includes('Analyzing') || msg.textContent.includes('generating')) { + msg.remove(); + } + }); + } + }; + + /** + * AJAX handler factory for content strategy requests. + * + * Creates a Drupal.ajax settings object that uses the Drupal AJAX framework + * for command processing while providing customized button states. + * + * @param {Object} options - Handler options. + * @param {HTMLElement} options.element - Triggering element. + * @param {string} options.url - Request URL (relative to base path). + * @param {string} options.loadingText - Text displayed during loading. + * @param {string} options.successText - Text displayed on success. + * @param {string} options.errorText - Text displayed on error. + * @param {Function} options.onSuccess - Success callback (receives context). + * @param {Object} drupalSettings - drupalSettings object. + * @returns {Object} AJAX settings object for Drupal.Ajax constructor. + */ + Drupal.aiContentStrategy.createAjaxHandler = function({ + element, + url, + loadingText, + successText, + errorText, + onSuccess + }, drupalSettings) { + const DOMUtils = Drupal.aiContentStrategy.DOMUtils; + + if (!drupalSettings?.aiContentStrategy?.buttonText) { + throw new Error('Button text settings are not available.'); + } + + if (!loadingText || !successText || !errorText) { + throw new Error('Required button texts are missing.'); + } + + DOMUtils.ensureElementId(element, 'content-strategy'); + + // Store original text for restoration. + const originalText = element.textContent.trim(); + + return { + base: element.id, + element: element, + url: Drupal.url(url), + submit: { js: true }, + progress: { type: 'throbber' }, + + beforeSend: function(xhr, settings) { + element.textContent = loadingText; + element.disabled = true; + Drupal.announce(loadingText, 'polite'); + return true; + }, + + // Let Drupal handle success - commands are processed automatically. + // We use complete to reset button state and run callbacks. + complete: function(response, status) { + DOMUtils.clearProgressMessages(); + element.disabled = false; + + // Check if there was an error in the response. + let hasError = false; + if (response && response.responseJSON) { + const data = response.responseJSON; + if (Array.isArray(data)) { + data.forEach((cmd) => { + if (cmd.command === 'message' && cmd.messageOptions?.type === 'error') { + hasError = true; + } + }); + } + } + + if (hasError || status === 'error') { + element.textContent = errorText || originalText; + } + else { + element.textContent = successText || originalText; + Drupal.announce(Drupal.t('Content loaded successfully'), 'polite'); + + // Run success callback if provided. + if (onSuccess) { + const mainContainer = document.querySelector('.content-strategy-recommendations'); + onSuccess(mainContainer); + } + } + }, + + error: function(xhr, status, error) { + DOMUtils.clearProgressMessages(); + element.disabled = false; + element.textContent = errorText || originalText; + + // Show error message. + const messages = new Drupal.Message(); + try { + const response = JSON.parse(xhr.responseText); + if (response[0]?.message) { + messages.add(response[0].message, { + type: 'error', + id: `content-strategy-error-${Date.now()}`, + announce: response[0].message + }); + } + else { + messages.add(Drupal.t('An error occurred while processing your request.'), { + type: 'error', + id: `content-strategy-error-${Date.now()}` + }); + } + } + catch (e) { + messages.add(Drupal.t('An error occurred while processing your request.'), { + type: 'error', + id: `content-strategy-error-${Date.now()}` + }); + } + } + }; + }; + +})(Drupal); diff --git a/src/Controller/ContentStrategyController.php b/src/Controller/ContentStrategyController.php index a4ef232..5b01329 100644 --- a/src/Controller/ContentStrategyController.php +++ b/src/Controller/ContentStrategyController.php @@ -2,11 +2,15 @@ namespace Drupal\ai_content_strategy\Controller; +use Drupal\Core\Ajax\DataCommand; use Drupal\ai_content_strategy\Entity\RecommendationCategory; use Drupal\Core\Controller\ControllerBase; use Drupal\ai_content_strategy\Service\StrategyGenerator; use Drupal\ai_content_strategy\Service\ContentAnalyzer; use Drupal\ai_content_strategy\Service\CategoryPromptBuilder; +use Drupal\ai_content_strategy\Service\RecommendationStorageService; +use Drupal\ai_content_strategy\Service\IdeaRowBuilder; +use Drupal\ai_content_strategy\Service\AjaxResponseBuilder; use Drupal\ai\AiProviderPluginManager; use Drupal\ai\Service\PromptJsonDecoder\PromptJsonDecoderInterface; use Drupal\Component\Serialization\Json; @@ -28,6 +32,7 @@ use Drupal\Core\Ajax\InvokeCommand; use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; use Drupal\Component\Datetime\TimeInterface; +use Symfony\Component\HttpFoundation\RequestStack; /** * Controller for content strategy functionality. @@ -114,6 +119,34 @@ class ContentStrategyController extends ControllerBase { */ protected $categoryPromptBuilder; + /** + * The request stack. + * + * @var \Symfony\Component\HttpFoundation\RequestStack + */ + protected $requestStack; + + /** + * The recommendation storage service. + * + * @var \Drupal\ai_content_strategy\Service\RecommendationStorageService + */ + protected $recommendationStorage; + + /** + * The idea row builder service. + * + * @var \Drupal\ai_content_strategy\Service\IdeaRowBuilder + */ + protected $ideaRowBuilder; + + /** + * The AJAX response builder service. + * + * @var \Drupal\ai_content_strategy\Service\AjaxResponseBuilder + */ + protected $ajaxResponseBuilder; + /** * Constructs a ContentStrategyController object. * @@ -137,6 +170,14 @@ class ContentStrategyController extends ControllerBase { * The time service. * @param \Drupal\ai_content_strategy\Service\CategoryPromptBuilder $category_prompt_builder * The category prompt builder service. + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * The request stack. + * @param \Drupal\ai_content_strategy\Service\RecommendationStorageService $recommendation_storage + * The recommendation storage service. + * @param \Drupal\ai_content_strategy\Service\IdeaRowBuilder $idea_row_builder + * The idea row builder service. + * @param \Drupal\ai_content_strategy\Service\AjaxResponseBuilder $ajax_response_builder + * The AJAX response builder service. */ public function __construct( StrategyGenerator $strategy_generator, @@ -149,6 +190,10 @@ public function __construct( KeyValueFactoryInterface $key_value_factory, TimeInterface $time, CategoryPromptBuilder $category_prompt_builder, + RequestStack $request_stack, + RecommendationStorageService $recommendation_storage, + IdeaRowBuilder $idea_row_builder, + AjaxResponseBuilder $ajax_response_builder, ) { $this->strategyGenerator = $strategy_generator; $this->contentAnalyzer = $content_analyzer; @@ -160,6 +205,10 @@ public function __construct( $this->keyValue = $key_value_factory->get(self::KV_COLLECTION); $this->time = $time; $this->categoryPromptBuilder = $category_prompt_builder; + $this->requestStack = $request_stack; + $this->recommendationStorage = $recommendation_storage; + $this->ideaRowBuilder = $idea_row_builder; + $this->ajaxResponseBuilder = $ajax_response_builder; } /** @@ -176,7 +225,11 @@ public static function create(ContainerInterface $container) { $container->get('date.formatter'), $container->get('keyvalue'), $container->get('datetime.time'), - $container->get('ai_content_strategy.category_prompt_builder') + $container->get('ai_content_strategy.category_prompt_builder'), + $container->get('request_stack'), + $container->get('ai_content_strategy.recommendation_storage'), + $container->get('ai_content_strategy.idea_row_builder'), + $container->get('ai_content_strategy.ajax_response_builder') ); } @@ -201,18 +254,33 @@ public function recommendations() { $stored_data = $this->keyValue->get(self::KV_KEY); $recommendations = []; $last_run = NULL; + $pages_analyzed = NULL; if ($stored_data) { if (is_array($stored_data) && isset($stored_data['data'], $stored_data['timestamp'])) { $recommendations = $stored_data['data']; - $last_run = (int) $stored_data['timestamp']; + $last_run = $stored_data['timestamp']; + $pages_analyzed = $stored_data['pages_analyzed'] ?? NULL; } else { // Handle legacy format. $recommendations = $stored_data; $last_run = $this->time->getRequestTime(); } + + // Migrate existing data by adding UUIDs if missing. + $original_recommendations = $recommendations; + $recommendations = $this->recommendationStorage->ensureUuids($recommendations); + $recommendations = $this->recommendationStorage->ensureIdeaUuids($recommendations); + if ($recommendations !== $original_recommendations) { + // UUIDs were added, save the updated data. + $this->keyValue->set(self::KV_KEY, [ + 'data' => $recommendations, + 'timestamp' => $last_run, + 'pages_analyzed' => $pages_analyzed, + ]); + } } // Load enabled categories and build category metadata. @@ -245,6 +313,8 @@ public function recommendations() { '#categories' => $categories, '#last_run' => $last_run ? $this->dateFormatter->formatTimeDiffSince($last_run) : NULL, + '#pages_analyzed' => $pages_analyzed, + '#categories_count' => count($category_ids), '#attached' => [ 'library' => ['ai_content_strategy/content_strategy'], ], @@ -259,32 +329,115 @@ public function recommendations() { */ public function generateRecommendationsAjax() { try { + // Get site data for metadata. + $sitemap_urls = $this->contentAnalyzer->getSitemapUrls(); + $pages_count = count($sitemap_urls['urls'] ?? []); + // Get recommendations. $recommendations = $this->strategyGenerator->generateRecommendations(); - // Store the results with timestamp in key-value store. - $timestamp = (int) $this->time->getCurrentTime(); + // Ensure all items and ideas have UUIDs for consistent referencing. + $recommendations = $this->recommendationStorage->ensureUuids($recommendations); + $recommendations = $this->recommendationStorage->ensureIdeaUuids($recommendations); + + // Store the results with timestamp and metadata in key-value store. + $timestamp = $this->time->getCurrentTime(); $this->keyValue->set(self::KV_KEY, [ 'data' => $recommendations, 'timestamp' => $timestamp, + 'pages_analyzed' => $pages_count, ]); // Create AJAX response. $response = new AjaxResponse(); - // Re-enable the generate button. + // Update button text to reflect that recommendations now exist. $response->addCommand(new HtmlCommand( '.generate-recommendations', - $this->t('Regenerate report') + $this->t('Regenerate recommendations') + )); + + // Update data attribute to reflect existing recommendations. + $response->addCommand(new InvokeCommand( + '.generate-recommendations', + 'attr', + ['data-has-existing', 'true'] )); - // Update the last run time. + // Get category count for status display. + $category_storage = $this->entityTypeManager()->getStorage('recommendation_category'); + $category_count = $category_storage->getQuery() + ->condition('status', TRUE) + ->accessCheck(FALSE) + ->count() + ->execute(); + + // Build the status area HTML. + $status_build = [ + '#type' => 'container', + '#attributes' => ['class' => ['content-strategy-status']], + 'timestamp' => [ + '#type' => 'container', + '#attributes' => ['class' => ['status-item', 'status-item--timestamp']], + 'label' => [ + '#type' => 'html_tag', + '#tag' => 'strong', + '#value' => $this->t('Last generated:'), + ], + 'value' => [ + '#markup' => ' ' . $this->dateFormatter->formatTimeDiffSince($timestamp), + ], + ], + 'pages' => [ + '#type' => 'container', + '#attributes' => ['class' => ['status-item', 'status-item--pages']], + 'label' => [ + '#type' => 'html_tag', + '#tag' => 'strong', + '#value' => $this->t('Pages analyzed:'), + ], + 'value' => [ + '#markup' => ' ' . $pages_count, + ], + ], + 'categories' => [ + '#type' => 'container', + '#attributes' => ['class' => ['status-item', 'status-item--categories']], + 'label' => [ + '#type' => 'html_tag', + '#tag' => 'strong', + '#value' => $this->t('Active categories:'), + ], + 'value' => [ + '#markup' => ' ' . $category_count, + ], + ], + ]; + + // Remove old status area if it exists and add the new one. + $response->addCommand(new RemoveCommand('.content-strategy-status')); $response->addCommand( - new HtmlCommand( - '.last-run-time', - $this->t('Last generated: @time ago', [ - '@time' => $this->dateFormatter->formatTimeDiffSince($timestamp), - ]) + new BeforeCommand( + '.content-strategy-actions', + $this->renderer->renderRoot($status_build) + ) + ); + + // Add CSV export button if it doesn't exist (first generation). + // Remove any existing one first to prevent duplicates on regenerate. + $response->addCommand(new RemoveCommand('.export-csv-button')); + $export_button = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#attributes' => [ + 'class' => ['export-csv-button', 'button', 'button--secondary'], + ], + '#value' => $this->t('Export as CSV'), + ]; + $response->addCommand( + new AppendCommand( + '.content-strategy-actions', + $this->renderer->renderRoot($export_button) ) ); @@ -349,7 +502,7 @@ public function generateRecommendationsAjax() { $has_items = !empty($recommendations[$category_id]); $button_class = $has_items ? 'button--secondary' : 'button--primary'; $button_text = $has_items - ? ($button_texts['add_more'][$category_id] ?? $this->t('Add AI recommendations')) + ? ($button_texts['add_more'][$category_id] ?? $this->t('Generate AI recommendations')) : ($button_texts['generate'][$category_id] ?? $this->t('Generate AI recommendations')); // Add empty state message for categories with no items. @@ -479,13 +632,13 @@ protected function getFrontPageContent(): string { * * @param string $section * The section to generate ideas for (content_gaps, authority_topics, etc.). - * @param string $title - * The title of the specific item to generate more ideas for. + * @param string $uuid + * The UUID of the specific item to generate more ideas for. * * @return \Drupal\Core\Ajax\AjaxResponse * AJAX response containing the new content ideas. */ - public function generateMore(string $section, string $title) { + public function generateMore(string $section, string $uuid) { try { // Get current stored data first. $stored_data = $this->keyValue->get(self::KV_KEY); @@ -494,6 +647,13 @@ public function generateMore(string $section, string $title) { } $recommendations = $stored_data['data']; + // Find the card by UUID to get its title for the prompt. + $card = $this->recommendationStorage->getCardByUuid($section, $uuid); + if (!$card) { + throw new \RuntimeException('Card not found'); + } + $title = $card['title'] ?? $card['topic'] ?? $card['content_type'] ?? $card['signal'] ?? ''; + // Get site data for context. $site_structure = $this->contentAnalyzer->getSiteStructure(); $sitemap_urls = $this->contentAnalyzer->getSitemapUrls(); @@ -568,81 +728,61 @@ public function generateMore(string $section, string $title) { ); } + // Normalize ideas to include UUIDs. + $normalized_ideas = $this->recommendationStorage->normalizeIdeasWithUuids($ideas); + // After successfully generating and parsing new ideas, update the stored // data. - $updated = FALSE; + $card_index = NULL; - // Find the correct section and item to update. + // Find the correct section and item to update by UUID. if (isset($recommendations[$section])) { foreach ($recommendations[$section] as $key => $item) { - // Try common title fields to match the item. - $item_title = $item['title'] ?? $item['topic'] ?? $item['content_type'] ?? $item['signal'] ?? NULL; - - if ($item_title === $title) { + if (isset($item['uuid']) && $item['uuid'] === $uuid) { // Append new ideas to existing ones (or initialize if empty). $existing_ideas = $recommendations[$section][$key]['content_ideas'] ?? []; $recommendations[$section][$key]['content_ideas'] = array_merge( $existing_ideas, - $ideas + $normalized_ideas ); - $updated = TRUE; + $card_index = $key; break; } } } - if ($updated) { - // Update the stored data with timestamp. - $timestamp = (int) $this->time->getCurrentTime(); - $this->keyValue->set(self::KV_KEY, [ - 'data' => $recommendations, - 'timestamp' => $timestamp, - ]); - } - else { + if ($card_index === NULL) { throw new \RuntimeException('Failed to find matching item to update'); } - // Build HTML for new ideas. - $rows = []; - foreach ($ideas as $idea) { - $rows[] = [ - '#type' => 'html_tag', - '#tag' => 'tr', - 'cell' => [ - '#type' => 'html_tag', - '#tag' => 'td', - '#value' => $idea, - ], - ]; - } - - // Create AJAX response. - $response = new AjaxResponse(); - - // Build the HTML for the new rows. - $html = $this->renderer->renderRoot($rows); + // Update the stored data with timestamp. + $timestamp = $this->time->getCurrentTime(); + $this->keyValue->set(self::KV_KEY, [ + 'data' => $recommendations, + 'timestamp' => $timestamp, + ]); - // Update the last run time. - $response->addCommand( - new HtmlCommand( - '.last-run-time', - $this->t('Last generated: @time ago', [ - '@time' => $this->dateFormatter->formatTimeDiffSince($timestamp), - ]) - ) + // Build HTML for new ideas using the idea row builder service. + $rows_html = $this->ideaRowBuilder->renderRows( + $section, + $uuid, + $normalized_ideas ); + // Create AJAX response and update timestamp. + $response = $this->ajaxResponseBuilder->create(); + $this->ajaxResponseBuilder->addTimestampCommand($response, $timestamp); + // Add command to append the new rows to the table. $response->addCommand( new AppendCommand( sprintf( - '.recommendation-item[data-section="%s"][data-title="%s"]' . + '.recommendation-item[data-section="%s"][data-uuid="%s"]' . ' .content-ideas-table tbody', $section, - str_replace('"', '\"', $title) + $uuid ), - $html + $rows_html ) ); @@ -876,7 +1016,7 @@ public function addMoreRecommendations($section) { $response->addCommand( new HtmlCommand( ".recommendation-section[data-section='$section'] .add-more-recommendations-link", - $button_texts['add_more'][$section] ?? $this->t('Add more AI recommendations') + $button_texts['add_more'][$section] ?? $this->t('Generate more AI recommendations') ) ); @@ -906,14 +1046,12 @@ public function addMoreRecommendations($section) { ); } - // Update the last run time. + // Update the timestamp in the status area. $response->addCommand( new HtmlCommand( - '.last-run-time', - $this->t('Last generated: @time ago', [ - '@time' => $this->dateFormatter - ->formatTimeDiffSince($stored_data['timestamp']), - ]) + '.status-item--timestamp', + '' . $this->t('Last generated:') . ' ' . + $this->dateFormatter->formatTimeDiffSince($stored_data['timestamp']) ) ); @@ -1060,4 +1198,293 @@ protected function getHttpStatusFromException(\Exception $exception): int { return 500; } + /** + * Deletes a recommendation card via AJAX. + * + * @param string $section + * The category section (e.g., 'content_gaps'). + * @param string $uuid + * The UUID of the card to delete. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * AJAX response with removal commands. + */ + public function deleteCard(string $section, string $uuid) { + $response = new AjaxResponse(); + + try { + // Load stored data. + $stored_data = $this->keyValue->get(self::KV_KEY); + + if (!$stored_data || !isset($stored_data['data'][$section])) { + throw new \RuntimeException('No data found for this section'); + } + + $recommendations = $stored_data['data']; + + // Find and remove the card by UUID. + $found = FALSE; + foreach ($recommendations[$section] as $key => $card) { + if (isset($card['uuid']) && $card['uuid'] === $uuid) { + unset($recommendations[$section][$key]); + $found = TRUE; + break; + } + } + + if (!$found) { + throw new \RuntimeException('Card not found'); + } + + // Re-index array. + $recommendations[$section] = array_values($recommendations[$section]); + + // Save updated data. + $stored_data['data'] = $recommendations; + $this->keyValue->set(self::KV_KEY, $stored_data); + + // Remove card from DOM. + $response->addCommand( + new RemoveCommand(".recommendation-item[data-section='$section'][data-uuid='$uuid']") + ); + + // If category is now empty, show empty state. + if (empty($recommendations[$section])) { + $category_storage = $this->entityTypeManager()->getStorage('recommendation_category'); + $category = $category_storage->load($section); + + if ($category) { + $button_texts = ai_content_strategy_get_button_texts(); + + $empty_state = [ + '#type' => 'container', + '#attributes' => ['class' => ['empty-category-state']], + 'message' => [ + '#type' => 'html_tag', + '#tag' => 'p', + '#value' => $this->t('No recommendations yet. Click below to generate AI-powered content suggestions.'), + ], + 'button_wrapper' => [ + '#type' => 'container', + '#attributes' => ['class' => ['add-more-recommendations-wrapper']], + 'button' => [ + '#type' => 'html_tag', + '#tag' => 'a', + '#attributes' => [ + 'href' => '#', + 'class' => ['add-more-recommendations-link', 'button', 'button--primary'], + 'data-section' => $section, + ], + '#value' => $button_texts['generate'][$section] ?? $this->t('Generate AI recommendations'), + ], + ], + ]; + + $html = $this->renderer->renderRoot($empty_state); + + // Remove the recommendation-items container. + $response->addCommand( + new RemoveCommand(".recommendation-section[data-section='$section'] .recommendation-items") + ); + + // Add empty state before the existing add-more button wrapper. + $response->addCommand( + new BeforeCommand( + ".recommendation-section[data-section='$section'] .add-more-recommendations-wrapper", + $html + ) + ); + + // Remove the old add-more wrapper. + $response->addCommand( + new RemoveCommand(".recommendation-section[data-section='$section'] .add-more-recommendations-wrapper:last-child") + ); + } + } + + // Show success message. + $response->addCommand( + new MessageCommand( + $this->t('Recommendation deleted successfully.'), + NULL, + ['type' => 'status'] + ) + ); + + } + catch (\Exception $e) { + Error::logException($this->getLogger('ai_content_strategy'), $e); + + $response->addCommand( + new MessageCommand( + $this->t('Error deleting recommendation: @error', ['@error' => $e->getMessage()]), + NULL, + ['type' => 'error'] + ) + ); + + $status_code = $this->getHttpStatusFromException($e); + $response->setStatusCode($status_code); + } + + return $response; + } + + /** + * Deletes an individual content idea from a recommendation card via AJAX. + * + * @param string $section + * The category section. + * @param string $uuid + * The UUID of the recommendation card. + * @param string $idea_uuid + * The UUID of the content idea to delete. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The AJAX response. + */ + public function deleteIdea(string $section, string $uuid, string $idea_uuid) { + $response = new AjaxResponse(); + + try { + // Use the storage service to delete the idea by UUID. + $this->recommendationStorage->deleteIdeaByUuid($section, $uuid, $idea_uuid); + + // Remove the row from DOM using idea UUID selector. + $response->addCommand( + new RemoveCommand(".recommendation-item[data-section='$section'][data-uuid='$uuid'] tr[data-idea-uuid='$idea_uuid']") + ); + + // Show success message. + $response->addCommand( + new MessageCommand( + $this->t('Content idea deleted successfully.'), + NULL, + ['type' => 'status'] + ) + ); + + } + catch (\Exception $e) { + Error::logException($this->getLogger('ai_content_strategy'), $e); + + $response->addCommand( + new MessageCommand( + $this->t('Error deleting content idea: @error', ['@error' => $e->getMessage()]), + NULL, + ['type' => 'error'] + ) + ); + + $status_code = $this->getHttpStatusFromException($e); + $response->setStatusCode($status_code); + } + + return $response; + } + + /** + * Saves edits to a recommendation card via AJAX. + * + * @param string $section + * The category section. + * @param string $uuid + * The UUID of the card. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * AJAX response confirming save. + */ + public function saveCard(string $section, string $uuid) { + $response = new AjaxResponse(); + + try { + // Get POST data. + $request = $this->requestStack->getCurrentRequest(); + $field = $request->request->get('field'); + $value = $request->request->get('value'); + $idea_uuid = $request->request->get('idea_uuid'); + + if (empty($field) || $value === NULL) { + throw new \InvalidArgumentException('Missing field or value'); + } + + // Update the field based on type. + switch ($field) { + case 'title': + $this->recommendationStorage->updateCardFieldByUuid($section, $uuid, 'title', strip_tags($value)); + break; + + case 'description': + $this->recommendationStorage->updateCardFieldByUuid($section, $uuid, 'description', strip_tags($value)); + break; + + case 'content_ideas': + if ($idea_uuid !== NULL) { + $this->recommendationStorage->updateIdeaFieldByUuid($section, $uuid, $idea_uuid, 'text', strip_tags($value)); + } + break; + + case 'implemented': + if ($idea_uuid !== NULL) { + $is_implemented = $value === '1' || $value === 'true'; + $this->recommendationStorage->updateIdeaFieldByUuid($section, $uuid, $idea_uuid, 'implemented', $is_implemented); + } + break; + + case 'link': + if ($idea_uuid !== NULL) { + $link_value = strip_tags(trim($value)); + $this->recommendationStorage->updateIdeaFieldByUuid($section, $uuid, $idea_uuid, 'link', $link_value); + + // Render the updated link area using the IdeaRowBuilder service. + $link_html = $this->ideaRowBuilder->renderLinkArea( + $section, + $uuid, + $idea_uuid, + $link_value + ); + + // Build selector for the link area within this specific idea row. + $card_selector = sprintf( + '.recommendation-item[data-section="%s"][data-uuid="%s"] tr[data-idea-uuid="%s"] .idea-link-area', + $section, + $uuid, + $idea_uuid + ); + + // Return HTML replacement command. + $response->addCommand(new HtmlCommand($card_selector, $link_html)); + + return $response; + } + break; + + default: + throw new \InvalidArgumentException('Invalid field'); + } + + // Return success (no visual command needed, JS will handle feedback). + $response->addCommand( + new DataCommand('.save-indicator', 'saved', 'true') + ); + + } + catch (\Exception $e) { + Error::logException($this->getLogger('ai_content_strategy'), $e); + + $response->addCommand( + new MessageCommand( + $this->t('Error saving: @error', ['@error' => $e->getMessage()]), + NULL, + ['type' => 'error'] + ) + ); + + $status_code = $this->getHttpStatusFromException($e); + $response->setStatusCode($status_code); + } + + return $response; + } + } diff --git a/src/Service/AjaxResponseBuilder.php b/src/Service/AjaxResponseBuilder.php new file mode 100644 index 0000000..bc9d3ee --- /dev/null +++ b/src/Service/AjaxResponseBuilder.php @@ -0,0 +1,245 @@ +dateFormatter = $date_formatter; + } + + /** + * Creates a new AJAX response. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * A new AJAX response object. + */ + public function create(): AjaxResponse { + return new AjaxResponse(); + } + + /** + * Creates a success response with a message. + * + * @param string $message + * The success message. + * @param \Drupal\Core\Ajax\AjaxResponse|null $response + * Optional existing response to add to. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The AJAX response with message command. + */ + public function createSuccessResponse(string $message, ?AjaxResponse $response = NULL): AjaxResponse { + $response = $response ?? new AjaxResponse(); + $response->addCommand(new MessageCommand($message, NULL, ['type' => 'status'])); + return $response; + } + + /** + * Creates an error response with a message. + * + * @param string $message + * The error message. + * @param int $status_code + * The HTTP status code. + * @param \Drupal\Core\Ajax\AjaxResponse|null $response + * Optional existing response to add to. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The AJAX response with error message. + */ + public function createErrorResponse(string $message, int $status_code = 500, ?AjaxResponse $response = NULL): AjaxResponse { + $response = $response ?? new AjaxResponse(); + $response->addCommand(new MessageCommand($message, NULL, ['type' => 'error'])); + $response->setStatusCode($status_code); + return $response; + } + + /** + * Adds an append command to insert HTML. + * + * @param \Drupal\Core\Ajax\AjaxResponse $response + * The response to add to. + * @param string $selector + * The CSS selector. + * @param string $html + * The HTML to append. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The response with append command. + */ + public function addAppendCommand(AjaxResponse $response, string $selector, string $html): AjaxResponse { + $response->addCommand(new AppendCommand($selector, $html)); + return $response; + } + + /** + * Adds an HTML replace command. + * + * @param \Drupal\Core\Ajax\AjaxResponse $response + * The response to add to. + * @param string $selector + * The CSS selector. + * @param string $html + * The HTML to replace with. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The response with HTML command. + */ + public function addHtmlCommand(AjaxResponse $response, string $selector, string $html): AjaxResponse { + $response->addCommand(new HtmlCommand($selector, $html)); + return $response; + } + + /** + * Adds a remove command. + * + * @param \Drupal\Core\Ajax\AjaxResponse $response + * The response to add to. + * @param string $selector + * The CSS selector of element to remove. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The response with remove command. + */ + public function addRemoveCommand(AjaxResponse $response, string $selector): AjaxResponse { + $response->addCommand(new RemoveCommand($selector)); + return $response; + } + + /** + * Adds an invoke command to call a jQuery method. + * + * @param \Drupal\Core\Ajax\AjaxResponse $response + * The response to add to. + * @param string $selector + * The CSS selector. + * @param string $method + * The jQuery method to call. + * @param array $arguments + * Arguments to pass to the method. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The response with invoke command. + */ + public function addInvokeCommand(AjaxResponse $response, string $selector, string $method, array $arguments = []): AjaxResponse { + $response->addCommand(new InvokeCommand($selector, $method, $arguments)); + return $response; + } + + /** + * Adds a command to update the timestamp display. + * + * @param \Drupal\Core\Ajax\AjaxResponse $response + * The response to add to. + * @param int $timestamp + * The timestamp to display. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The response with timestamp update command. + */ + public function addTimestampCommand(AjaxResponse $response, int $timestamp): AjaxResponse { + $html = '' . $this->t('Last generated:') . ' ' . + $this->dateFormatter->formatTimeDiffSince($timestamp); + + $response->addCommand(new HtmlCommand('.status-item--timestamp', $html)); + return $response; + } + + /** + * Adds a message command. + * + * @param \Drupal\Core\Ajax\AjaxResponse $response + * The response to add to. + * @param string $message + * The message text. + * @param string $type + * The message type (status, warning, error). + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The response with message command. + */ + public function addMessageCommand(AjaxResponse $response, string $message, string $type = 'status'): AjaxResponse { + $response->addCommand(new MessageCommand($message, NULL, ['type' => $type])); + return $response; + } + + /** + * Builds the selector for a recommendation item. + * + * @param string $section + * The section identifier. + * @param string $title + * The card title. + * + * @return string + * The CSS selector. + */ + public function buildCardSelector(string $section, string $title): string { + $escaped_title = str_replace(["'", '"'], ["\\'", '\\"'], $title); + return ".recommendation-item[data-section='{$section}'][data-title='{$escaped_title}']"; + } + + /** + * Builds the selector for an idea row. + * + * @param string $section + * The section identifier. + * @param string $title + * The card title. + * @param int $idea_index + * The idea index. + * + * @return string + * The CSS selector. + */ + public function buildIdeaRowSelector(string $section, string $title, int $idea_index): string { + return $this->buildCardSelector($section, $title) . " tr[data-idea-index='{$idea_index}']"; + } + + /** + * Builds the selector for a card's table body. + * + * @param string $section + * The section identifier. + * @param string $title + * The card title. + * + * @return string + * The CSS selector. + */ + public function buildTableBodySelector(string $section, string $title): string { + return $this->buildCardSelector($section, $title) . ' .content-ideas-table tbody'; + } + +} diff --git a/src/Service/IdeaRowBuilder.php b/src/Service/IdeaRowBuilder.php new file mode 100644 index 0000000..82c51f6 --- /dev/null +++ b/src/Service/IdeaRowBuilder.php @@ -0,0 +1,170 @@ +renderer = $renderer; + } + + /** + * Builds a render array for a single idea row. + * + * @param string $section + * The section identifier. + * @param string $card_uuid + * The card UUID. + * @param array|string $idea + * The idea data (string or array with text/implemented/link/uuid). + * + * @return array + * A render array for the idea row. + */ + public function buildRow(string $section, string $card_uuid, $idea): array { + // Normalize idea to array format. + if (is_string($idea)) { + $idea = [ + 'text' => $idea, + 'implemented' => FALSE, + 'link' => '', + 'uuid' => '', + ]; + } + + $idea_text = $idea['text'] ?? $idea; + $idea_implemented = $idea['implemented'] ?? FALSE; + $idea_link = $idea['link'] ?? ''; + $idea_uuid = $idea['uuid'] ?? ''; + + return [ + '#theme' => 'ai_content_strategy_idea_row', + '#section' => $section, + '#uuid' => $card_uuid, + '#idea_uuid' => $idea_uuid, + '#idea_text' => $idea_text, + '#idea_implemented' => $idea_implemented, + '#idea_link' => $idea_link, + ]; + } + + /** + * Builds render arrays for multiple idea rows. + * + * @param string $section + * The section identifier. + * @param string $card_uuid + * The card UUID. + * @param array $ideas + * Array of ideas (strings or idea objects with uuid). + * + * @return array + * Array of render arrays. + */ + public function buildRows(string $section, string $card_uuid, array $ideas): array { + $rows = []; + foreach ($ideas as $idea) { + $rows[] = $this->buildRow($section, $card_uuid, $idea); + } + return $rows; + } + + /** + * Renders idea rows to HTML string. + * + * @param string $section + * The section identifier. + * @param string $card_uuid + * The card UUID. + * @param array $ideas + * Array of ideas (must have uuid property). + * + * @return string + * The rendered HTML. + */ + public function renderRows(string $section, string $card_uuid, array $ideas): string { + $rows = $this->buildRows($section, $card_uuid, $ideas); + return (string) $this->renderer->renderRoot($rows); + } + + /** + * Builds a render array for the link area. + * + * @param string $section + * The section identifier. + * @param string $card_uuid + * The card UUID. + * @param string $idea_uuid + * The idea UUID. + * @param string $link + * The link URL (empty if no link). + * @param bool $implemented + * Whether the idea is implemented. + * + * @return array + * A render array for the link area. + */ + public function buildLinkArea(string $section, string $card_uuid, string $idea_uuid, string $link, bool $implemented = TRUE): array { + if ($link) { + return [ + '#theme' => 'ai_content_strategy_link_display', + '#link' => $link, + '#section' => $section, + '#uuid' => $card_uuid, + '#idea_uuid' => $idea_uuid, + ]; + } + else { + return [ + '#theme' => 'ai_content_strategy_link_add_button', + '#section' => $section, + '#uuid' => $card_uuid, + '#idea_uuid' => $idea_uuid, + ]; + } + } + + /** + * Renders link area to HTML string. + * + * @param string $section + * The section identifier. + * @param string $card_uuid + * The card UUID. + * @param string $idea_uuid + * The idea UUID. + * @param string $link + * The link URL. + * @param bool $implemented + * Whether the idea is implemented. + * + * @return string + * The rendered HTML. + */ + public function renderLinkArea(string $section, string $card_uuid, string $idea_uuid, string $link, bool $implemented = TRUE): string { + $build = $this->buildLinkArea($section, $card_uuid, $idea_uuid, $link, $implemented); + return (string) $this->renderer->renderRoot($build); + } + +} diff --git a/src/Service/RecommendationStorageService.php b/src/Service/RecommendationStorageService.php new file mode 100644 index 0000000..e38c777 --- /dev/null +++ b/src/Service/RecommendationStorageService.php @@ -0,0 +1,793 @@ +keyValue = $key_value_factory->get(self::KV_COLLECTION); + $this->time = $time; + $this->uuid = $uuid; + } + + /** + * Gets all stored recommendation data. + * + * @return array|null + * The stored data with 'data', 'timestamp', and optional 'pages_analyzed', + * or NULL if no data exists. + */ + public function getStoredData(): ?array { + $stored_data = $this->keyValue->get(self::KV_KEY); + + if (!$stored_data) { + return NULL; + } + + // Handle legacy format (direct array without metadata). + if (is_array($stored_data) && !isset($stored_data['data'])) { + return [ + 'data' => $stored_data, + 'timestamp' => NULL, + 'pages_analyzed' => NULL, + ]; + } + + return $stored_data; + } + + /** + * Gets recommendations data only. + * + * @return array + * The recommendations array, or empty array if none exist. + */ + public function getRecommendations(): array { + $stored = $this->getStoredData(); + return $stored['data'] ?? []; + } + + /** + * Gets the last generation timestamp. + * + * @return int|null + * The timestamp, or NULL if never generated. + */ + public function getLastRunTimestamp(): ?int { + $stored = $this->getStoredData(); + return $stored['timestamp'] ?? NULL; + } + + /** + * Gets the pages analyzed count. + * + * @return int|null + * The count, or NULL if not tracked. + */ + public function getPagesAnalyzed(): ?int { + $stored = $this->getStoredData(); + return $stored['pages_analyzed'] ?? NULL; + } + + /** + * Gets recommendations for a specific section. + * + * @param string $section + * The section identifier. + * + * @return array + * The section's recommendations, or empty array if none. + */ + public function getSection(string $section): array { + $recommendations = $this->getRecommendations(); + return $recommendations[$section] ?? []; + } + + /** + * Saves recommendations with metadata. + * + * @param array $recommendations + * The recommendations data. + * @param int|null $pages_analyzed + * Optional pages analyzed count. + * + * @return int + * The timestamp of the save operation. + */ + public function saveRecommendations(array $recommendations, ?int $pages_analyzed = NULL): int { + $timestamp = $this->time->getCurrentTime(); + + $data = [ + 'data' => $recommendations, + 'timestamp' => $timestamp, + ]; + + if ($pages_analyzed !== NULL) { + $data['pages_analyzed'] = $pages_analyzed; + } + + $this->keyValue->set(self::KV_KEY, $data); + + return $timestamp; + } + + /** + * Finds a card by title within a section. + * + * @param string $section + * The section identifier. + * @param string $title + * The card title to find. + * + * @return int|null + * The card index, or NULL if not found. + * + * @internal This method is kept for backwards compatibility. + * New code should use findCardIndexByUuid() instead. + */ + public function findCardIndex(string $section, string $title): ?int { + $recommendations = $this->getRecommendations(); + + if (!isset($recommendations[$section])) { + return NULL; + } + + foreach ($recommendations[$section] as $index => $card) { + $card_title = $card['title'] ?? $card['topic'] ?? $card['content_type'] ?? $card['signal'] ?? ''; + if ($card_title === $title) { + return $index; + } + } + + return NULL; + } + + /** + * Finds a card by UUID within a section. + * + * @param string $section + * The section identifier. + * @param string $uuid + * The card UUID to find. + * + * @return int|null + * The card index, or NULL if not found. + */ + public function findCardIndexByUuid(string $section, string $uuid): ?int { + $recommendations = $this->getRecommendations(); + + if (!isset($recommendations[$section])) { + return NULL; + } + + foreach ($recommendations[$section] as $index => $card) { + if (isset($card['uuid']) && $card['uuid'] === $uuid) { + return $index; + } + } + + return NULL; + } + + /** + * Gets a card by section and UUID. + * + * @param string $section + * The section identifier. + * @param string $uuid + * The card UUID. + * + * @return array|null + * The card data, or NULL if not found. + */ + public function getCardByUuid(string $section, string $uuid): ?array { + $index = $this->findCardIndexByUuid($section, $uuid); + + if ($index === NULL) { + return NULL; + } + + $recommendations = $this->getRecommendations(); + return $recommendations[$section][$index] ?? NULL; + } + + /** + * Gets a card by section and title. + * + * @param string $section + * The section identifier. + * @param string $title + * The card title. + * + * @return array|null + * The card data, or NULL if not found. + */ + public function getCard(string $section, string $title): ?array { + $index = $this->findCardIndex($section, $title); + + if ($index === NULL) { + return NULL; + } + + $recommendations = $this->getRecommendations(); + return $recommendations[$section][$index] ?? NULL; + } + + /** + * Appends ideas to a card. + * + * @param string $section + * The section identifier. + * @param string $title + * The card title. + * @param array $ideas + * The ideas to append (strings or idea objects). + * + * @return int + * The starting index of the new ideas. + * + * @throws \RuntimeException + * If the card is not found. + */ + public function appendIdeas(string $section, string $title, array $ideas): int { + $stored = $this->getStoredData(); + $recommendations = $stored['data'] ?? []; + + $card_index = $this->findCardIndex($section, $title); + if ($card_index === NULL) { + throw new \RuntimeException('Card not found'); + } + + $existing_ideas = $recommendations[$section][$card_index]['content_ideas'] ?? []; + $starting_index = count($existing_ideas); + + $recommendations[$section][$card_index]['content_ideas'] = array_merge( + $existing_ideas, + $ideas + ); + + $this->saveRecommendationsPreservingMetadata($recommendations, $stored); + + return $starting_index; + } + + /** + * Updates a field on a card. + * + * @param string $section + * The section identifier. + * @param string $title + * The card title. + * @param string $field + * The field name. + * @param mixed $value + * The new value. + * + * @return string|null + * The new title if title was changed, NULL otherwise. + * + * @throws \RuntimeException + * If the card is not found. + */ + public function updateCardField(string $section, string $title, string $field, $value): ?string { + $stored = $this->getStoredData(); + $recommendations = $stored['data'] ?? []; + + $card_index = $this->findCardIndex($section, $title); + if ($card_index === NULL) { + throw new \RuntimeException('Card not found'); + } + + $recommendations[$section][$card_index][$field] = $value; + + $this->saveRecommendationsPreservingMetadata($recommendations, $stored); + + // Return new title if title field was updated. + return $field === 'title' ? $value : NULL; + } + + /** + * Updates a field on an idea within a card. + * + * @param string $section + * The section identifier. + * @param string $title + * The card title. + * @param int $idea_index + * The idea index. + * @param string $field + * The field name ('text', 'implemented', 'link'). + * @param mixed $value + * The new value. + * + * @throws \RuntimeException + * If the card or idea is not found. + */ + public function updateIdeaField(string $section, string $title, int $idea_index, string $field, $value): void { + $stored = $this->getStoredData(); + $recommendations = $stored['data'] ?? []; + + $card_index = $this->findCardIndex($section, $title); + if ($card_index === NULL) { + throw new \RuntimeException('Card not found'); + } + + if (!isset($recommendations[$section][$card_index]['content_ideas'][$idea_index])) { + throw new \RuntimeException('Idea not found'); + } + + $idea = &$recommendations[$section][$card_index]['content_ideas'][$idea_index]; + + // Convert string idea to object format if needed. + if (is_string($idea)) { + $idea = [ + 'text' => $idea, + 'implemented' => FALSE, + 'link' => '', + ]; + } + + $idea[$field] = $value; + + $this->saveRecommendationsPreservingMetadata($recommendations, $stored); + } + + /** + * Deletes a card. + * + * @param string $section + * The section identifier. + * @param string $title + * The card title. + * + * @throws \RuntimeException + * If the card is not found. + */ + public function deleteCard(string $section, string $title): void { + $stored = $this->getStoredData(); + $recommendations = $stored['data'] ?? []; + + $card_index = $this->findCardIndex($section, $title); + if ($card_index === NULL) { + throw new \RuntimeException('Card not found'); + } + + unset($recommendations[$section][$card_index]); + $recommendations[$section] = array_values($recommendations[$section]); + + $this->saveRecommendationsPreservingMetadata($recommendations, $stored); + } + + /** + * Deletes an idea from a card. + * + * @param string $section + * The section identifier. + * @param string $title + * The card title. + * @param int $idea_index + * The idea index. + * + * @throws \RuntimeException + * If the card or idea is not found. + */ + public function deleteIdea(string $section, string $title, int $idea_index): void { + $stored = $this->getStoredData(); + $recommendations = $stored['data'] ?? []; + + $card_index = $this->findCardIndex($section, $title); + if ($card_index === NULL) { + throw new \RuntimeException('Card not found'); + } + + if (!isset($recommendations[$section][$card_index]['content_ideas'][$idea_index])) { + throw new \RuntimeException('Idea not found'); + } + + unset($recommendations[$section][$card_index]['content_ideas'][$idea_index]); + $recommendations[$section][$card_index]['content_ideas'] = array_values( + $recommendations[$section][$card_index]['content_ideas'] + ); + + $this->saveRecommendationsPreservingMetadata($recommendations, $stored); + } + + /** + * Adds recommendations to a section. + * + * @param string $section + * The section identifier. + * @param array $new_recommendations + * The recommendations to add. + */ + public function addToSection(string $section, array $new_recommendations): void { + $stored = $this->getStoredData(); + $recommendations = $stored['data'] ?? []; + + $existing = $recommendations[$section] ?? []; + $recommendations[$section] = array_merge($existing, $new_recommendations); + + $this->saveRecommendationsPreservingMetadata($recommendations, $stored); + } + + /** + * Saves recommendations while preserving existing metadata. + * + * @param array $recommendations + * The recommendations data. + * @param array|null $stored + * The existing stored data with metadata. + */ + protected function saveRecommendationsPreservingMetadata(array $recommendations, ?array $stored): void { + $timestamp = $this->time->getCurrentTime(); + + $data = [ + 'data' => $recommendations, + 'timestamp' => $timestamp, + ]; + + if (isset($stored['pages_analyzed'])) { + $data['pages_analyzed'] = $stored['pages_analyzed']; + } + + $this->keyValue->set(self::KV_KEY, $data); + } + + /** + * Ensures all items in the recommendations have UUIDs. + * + * This migrates existing data that doesn't have UUIDs. + * + * @param array $recommendations + * The recommendations array to process. + * + * @return array + * The recommendations with UUIDs added to any items missing them. + */ + public function ensureUuids(array $recommendations): array { + foreach ($recommendations as &$items) { + if (!is_array($items)) { + continue; + } + foreach ($items as &$item) { + if (is_array($item) && !isset($item['uuid'])) { + $item['uuid'] = $this->uuid->generate(); + } + } + } + + return $recommendations; + } + + /** + * Ensures all ideas within cards have UUIDs. + * + * This migrates existing ideas that don't have UUIDs. + * + * @param array $recommendations + * The recommendations array to process. + * + * @return array + * The recommendations with UUIDs added to any ideas missing them. + */ + public function ensureIdeaUuids(array $recommendations): array { + foreach ($recommendations as &$items) { + if (!is_array($items)) { + continue; + } + foreach ($items as &$item) { + if (!is_array($item) || !isset($item['content_ideas'])) { + continue; + } + foreach ($item['content_ideas'] as &$idea) { + // Convert string ideas to object format. + if (is_string($idea)) { + $idea = [ + 'text' => $idea, + 'implemented' => FALSE, + 'link' => '', + 'uuid' => $this->uuid->generate(), + ]; + } + elseif (is_array($idea) && !isset($idea['uuid'])) { + $idea['uuid'] = $this->uuid->generate(); + } + } + } + } + + return $recommendations; + } + + /** + * Converts idea strings to objects with UUIDs. + * + * Used when adding new ideas from AI to ensure they have UUIDs. + * + * @param array $ideas + * Array of ideas (strings or objects). + * + * @return array + * Array of idea objects with UUIDs. + */ + public function normalizeIdeasWithUuids(array $ideas): array { + $normalized = []; + foreach ($ideas as $idea) { + if (is_string($idea)) { + $normalized[] = [ + 'text' => $idea, + 'implemented' => FALSE, + 'link' => '', + 'uuid' => $this->uuid->generate(), + ]; + } + elseif (is_array($idea)) { + if (!isset($idea['uuid'])) { + $idea['uuid'] = $this->uuid->generate(); + } + $normalized[] = $idea; + } + } + return $normalized; + } + + /** + * Finds an idea by UUID within a card. + * + * @param string $section + * The section identifier. + * @param string $card_uuid + * The card UUID. + * @param string $idea_uuid + * The idea UUID to find. + * + * @return int|null + * The idea index, or NULL if not found. + */ + public function findIdeaIndexByUuid(string $section, string $card_uuid, string $idea_uuid): ?int { + $card = $this->getCardByUuid($section, $card_uuid); + if (!$card || !isset($card['content_ideas'])) { + return NULL; + } + + foreach ($card['content_ideas'] as $index => $idea) { + if (is_array($idea) && isset($idea['uuid']) && $idea['uuid'] === $idea_uuid) { + return $index; + } + } + + return NULL; + } + + /** + * Deletes a card by UUID. + * + * @param string $section + * The section identifier. + * @param string $uuid + * The card UUID. + * + * @throws \RuntimeException + * If the card is not found. + */ + public function deleteCardByUuid(string $section, string $uuid): void { + $stored = $this->getStoredData(); + $recommendations = $stored['data'] ?? []; + + $card_index = $this->findCardIndexByUuid($section, $uuid); + if ($card_index === NULL) { + throw new \RuntimeException('Card not found'); + } + + unset($recommendations[$section][$card_index]); + $recommendations[$section] = array_values($recommendations[$section]); + + $this->saveRecommendationsPreservingMetadata($recommendations, $stored); + } + + /** + * Deletes an idea from a card by UUID. + * + * @param string $section + * The section identifier. + * @param string $card_uuid + * The card UUID. + * @param string $idea_uuid + * The idea UUID. + * + * @throws \RuntimeException + * If the card or idea is not found. + */ + public function deleteIdeaByUuid(string $section, string $card_uuid, string $idea_uuid): void { + $stored = $this->getStoredData(); + $recommendations = $stored['data'] ?? []; + + $card_index = $this->findCardIndexByUuid($section, $card_uuid); + if ($card_index === NULL) { + throw new \RuntimeException('Card not found'); + } + + $idea_index = $this->findIdeaIndexByUuid($section, $card_uuid, $idea_uuid); + if ($idea_index === NULL) { + throw new \RuntimeException('Idea not found'); + } + + unset($recommendations[$section][$card_index]['content_ideas'][$idea_index]); + $recommendations[$section][$card_index]['content_ideas'] = array_values( + $recommendations[$section][$card_index]['content_ideas'] + ); + + $this->saveRecommendationsPreservingMetadata($recommendations, $stored); + } + + /** + * Updates a field on a card by UUID. + * + * @param string $section + * The section identifier. + * @param string $uuid + * The card UUID. + * @param string $field + * The field name. + * @param mixed $value + * The new value. + * + * @throws \RuntimeException + * If the card is not found. + */ + public function updateCardFieldByUuid(string $section, string $uuid, string $field, $value): void { + $stored = $this->getStoredData(); + $recommendations = $stored['data'] ?? []; + + $card_index = $this->findCardIndexByUuid($section, $uuid); + if ($card_index === NULL) { + throw new \RuntimeException('Card not found'); + } + + $recommendations[$section][$card_index][$field] = $value; + + $this->saveRecommendationsPreservingMetadata($recommendations, $stored); + } + + /** + * Updates a field on an idea within a card by UUID. + * + * @param string $section + * The section identifier. + * @param string $card_uuid + * The card UUID. + * @param string $idea_uuid + * The idea UUID. + * @param string $field + * The field name ('text', 'implemented', 'link'). + * @param mixed $value + * The new value. + * + * @throws \RuntimeException + * If the card or idea is not found. + */ + public function updateIdeaFieldByUuid(string $section, string $card_uuid, string $idea_uuid, string $field, $value): void { + $stored = $this->getStoredData(); + $recommendations = $stored['data'] ?? []; + + $card_index = $this->findCardIndexByUuid($section, $card_uuid); + if ($card_index === NULL) { + throw new \RuntimeException('Card not found'); + } + + $idea_index = $this->findIdeaIndexByUuid($section, $card_uuid, $idea_uuid); + if ($idea_index === NULL) { + throw new \RuntimeException('Idea not found'); + } + + $idea = &$recommendations[$section][$card_index]['content_ideas'][$idea_index]; + + // Convert string idea to object format if needed. + if (is_string($idea)) { + $idea = [ + 'text' => $idea, + 'implemented' => FALSE, + 'link' => '', + 'uuid' => $idea_uuid, + ]; + } + + $idea[$field] = $value; + + $this->saveRecommendationsPreservingMetadata($recommendations, $stored); + } + + /** + * Appends ideas to a card by UUID. + * + * @param string $section + * The section identifier. + * @param string $uuid + * The card UUID. + * @param array $ideas + * The ideas to append (strings or idea objects). + * + * @return int + * The starting index of the new ideas. + * + * @throws \RuntimeException + * If the card is not found. + */ + public function appendIdeasByUuid(string $section, string $uuid, array $ideas): int { + $stored = $this->getStoredData(); + $recommendations = $stored['data'] ?? []; + + $card_index = $this->findCardIndexByUuid($section, $uuid); + if ($card_index === NULL) { + throw new \RuntimeException('Card not found'); + } + + $existing_ideas = $recommendations[$section][$card_index]['content_ideas'] ?? []; + $starting_index = count($existing_ideas); + + $recommendations[$section][$card_index]['content_ideas'] = array_merge( + $existing_ideas, + $ideas + ); + + $this->saveRecommendationsPreservingMetadata($recommendations, $stored); + + return $starting_index; + } + +} diff --git a/templates/ai-content-strategy-recommendations-items.html.twig b/templates/ai-content-strategy-recommendations-items.html.twig index 99f6f87..b3e1df0 100644 --- a/templates/ai-content-strategy-recommendations-items.html.twig +++ b/templates/ai-content-strategy-recommendations-items.html.twig @@ -21,16 +21,22 @@ {% elseif item_title is iterable %} {% set item_title = item_title|first %} {% endif %} + {% set item_uuid = item.uuid|default('') %} -
+
-

{{ item_title }}

+
+

{{ item_title }}

+ +
{# Try to find description - try common fields in order #} {% set description = item.description|default(item.rationale|default(item.implementation|default(null))) %} {% if description %} -

{{ description is iterable and description['#markup'] is defined ? description['#markup'] : description }}

+

{{ description is iterable and description['#markup'] is defined ? description['#markup'] : description }}

{% endif %} - + {% if item.priority %} {{ item.priority|title|t }} {% endif %} @@ -40,13 +46,24 @@ {{ 'Content Ideas'|t }} + {% for idea in item.content_ideas %} - - {{ idea is iterable and idea['#markup'] is defined ? idea['#markup'] : idea }} - + {# Support both string ideas (legacy) and object ideas with implemented status #} + {% set idea_text = idea.text is defined ? idea.text : idea %} + {% set idea_implemented = idea.implemented is defined ? idea.implemented : false %} + {% set idea_link = idea.link is defined ? idea.link : '' %} + {% set idea_uuid = idea.uuid is defined ? idea.uuid : '' %} + {% include '@ai_content_strategy/components/idea-row.html.twig' with { + 'section': section, + 'uuid': item_uuid, + 'idea_uuid': idea_uuid, + 'idea_text': idea_text is iterable and idea_text['#markup'] is defined ? idea_text['#markup'] : idea_text, + 'idea_implemented': idea_implemented, + 'idea_link': idea_link, + } only %} {% endfor %} @@ -54,7 +71,7 @@
{% if item.content_ideas is not empty %} - + {{ button_text.generate_more[section] }} {% else %} @@ -64,4 +81,4 @@ {% endif %}
-{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/templates/ai-content-strategy-recommendations.html.twig b/templates/ai-content-strategy-recommendations.html.twig index f64eaee..6bdde5c 100644 --- a/templates/ai-content-strategy-recommendations.html.twig +++ b/templates/ai-content-strategy-recommendations.html.twig @@ -4,12 +4,12 @@ * Default theme implementation for content strategy recommendations. * * Available variables: - * - content_gaps: Array of content gap recommendations. - * - authority_topics: Array of authority topic recommendations. - * - expertise_demonstrations: Array of expertise demonstration recommendations. - * - trust_signals: Array of trust signal recommendations. + * - categories: Array of recommendation categories with their items. * - button_text: Button text configuration. * - last_run: Last generation time as a formatted string. + * - pages_analyzed: Number of pages analyzed from the sitemap. + * - categories_count: Number of active recommendation categories. + * - configure_link: Link to configure categories (optional). * * @ingroup themeable */ @@ -25,18 +25,36 @@

{{ 'AI-powered content strategy recommendations based on your site structure.'|t }}

+ {% if last_run %} +
+
+ {{ 'Last generated:'|t }} {{ last_run }} +
+ {% if pages_analyzed %} +
+ {{ 'Pages analyzed:'|t }} {{ pages_analyzed }} +
+ {% endif %} + {% if categories_count %} +
+ {{ 'Active categories:'|t }} {{ categories_count }} +
+ {% endif %} +
+ {% endif %} +
- {% if last_run %} -
- {{ 'Last generated: @time'|t({'@time': last_run}) }} -
+ {% endif %}
diff --git a/templates/components/icon.html.twig b/templates/components/icon.html.twig new file mode 100644 index 0000000..f24287c --- /dev/null +++ b/templates/components/icon.html.twig @@ -0,0 +1,17 @@ +{# +/** + * @file + * Template for rendering icons using CSS mask-image. + * + * Uses CSS mask-image for colorable icons that inherit color from parent. + * The SVG files are referenced via CSS, allowing caching and color control. + * + * Available variables: + * - name: The icon name (trash, edit, checkmark, error). + * - class: Optional CSS class(es) to add. + * - size: Optional size variant (sm, md, lg). Default is base 16px. + * + * @ingroup themeable + */ +#} + diff --git a/templates/components/idea-row.html.twig b/templates/components/idea-row.html.twig new file mode 100644 index 0000000..d7577e3 --- /dev/null +++ b/templates/components/idea-row.html.twig @@ -0,0 +1,46 @@ +{# +/** + * @file + * Template for a single content idea row. + * + * Available variables: + * - section: The section identifier. + * - uuid: The card UUID. + * - idea_uuid: The UUID of the idea. + * - idea_text: The idea content text. + * - idea_implemented: Boolean indicating if the idea is implemented. + * - idea_link: Optional URL link for the idea. + * + * @ingroup themeable + */ +#} + + +
{{ idea_text }}
+ + + + + + + diff --git a/templates/components/link-add-button.html.twig b/templates/components/link-add-button.html.twig new file mode 100644 index 0000000..d1dfe39 --- /dev/null +++ b/templates/components/link-add-button.html.twig @@ -0,0 +1,14 @@ +{# +/** + * @file + * Template for the "Add link" button. + * + * Available variables: + * - section: The section identifier. + * - uuid: The card UUID. + * - idea_uuid: The UUID of the idea. + * + * @ingroup themeable + */ +#} + diff --git a/templates/components/link-display.html.twig b/templates/components/link-display.html.twig new file mode 100644 index 0000000..1cb30a5 --- /dev/null +++ b/templates/components/link-display.html.twig @@ -0,0 +1,18 @@ +{# +/** + * @file + * Template for displaying a saved link with edit button. + * + * Available variables: + * - link: The URL of the link. + * - section: The section identifier. + * - uuid: The card UUID. + * - idea_uuid: The UUID of the idea. + * + * @ingroup themeable + */ +#} +{{ link }} + diff --git a/templates/components/link-input.html.twig b/templates/components/link-input.html.twig new file mode 100644 index 0000000..28c8e92 --- /dev/null +++ b/templates/components/link-input.html.twig @@ -0,0 +1,16 @@ +{# +/** + * @file + * Template for the link input form. + * + * Available variables: + * - current_link: The current link value (for editing). + * + * @ingroup themeable + */ +#} +