PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.0.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / admin / class-manager.php

class-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.0.0, at includes/admin/class-manager.php

734 lines 23.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin Manager Class
4 *
5 * Handles WordPress admin interface integration
6 *
7 * @package ThinkRank\Admin
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace ThinkRank\Admin;
14
15 use ThinkRank\Core\Settings;
16 use ThinkRank\Core\Database;
17 use ThinkRank\Admin\Metabox_Manager;
18
19 // Prevent direct access
20 if (!defined('ABSPATH')) {
21 exit;
22 }
23
24 /**
25 * Admin Manager Class
26 *
27 * Single Responsibility: Manage WordPress admin interface
28 *
29 * @since 1.0.0
30 */
31 class Manager {
32
33 /**
34 * Settings instance
35 *
36 * @var Settings
37 */
38 private Settings $settings;
39
40 /**
41 * Database instance
42 *
43 * @var Database
44 */
45 private Database $database;
46
47 /**
48 * Metabox manager instance
49 *
50 * @var Metabox_Manager
51 */
52 private Metabox_Manager $metabox_manager;
53
54
55
56 /**
57 * Admin pages
58 *
59 * @var array
60 */
61 private array $pages = [];
62
63 /**
64 * Constructor
65 *
66 * @param Settings $settings Settings instance
67 * @param Database $database Database instance
68 */
69 public function __construct(Settings $settings = null, Database $database = null) {
70 $this->settings = $settings ?? new Settings();
71 $this->database = $database ?? new Database();
72 $this->metabox_manager = new Metabox_Manager($this->settings);
73 }
74
75 /**
76 * Initialize admin interface
77 *
78 * @return void
79 */
80 public function init(): void {
81 add_action('admin_menu', [$this, 'add_admin_menu']);
82 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
83 add_action('admin_init', [$this, 'handle_admin_init']);
84 add_action('admin_notices', [$this, 'show_admin_notices']);
85
86 // AJAX handlers
87 add_action('wp_ajax_thinkrank_dismiss_notice', [$this, 'dismiss_notice']);
88
89 // Initialize metabox manager
90 $this->metabox_manager->init();
91 }
92
93 /**
94 * Add admin menu pages
95 *
96 * @return void
97 */
98 public function add_admin_menu(): void {
99 // Main menu page
100 $this->pages['dashboard'] = add_menu_page(
101 __('ThinkRank', 'thinkrank'),
102 __('ThinkRank', 'thinkrank'),
103 'manage_options',
104 'thinkrank',
105 [$this, 'render_dashboard_page'],
106 $this->get_menu_icon(),
107 30
108 );
109
110 // Dashboard submenu (same as main)
111 $this->pages['dashboard_sub'] = add_submenu_page(
112 'thinkrank',
113 __('Dashboard', 'thinkrank'),
114 __('Dashboard', 'thinkrank'),
115 'manage_options',
116 'thinkrank',
117 [$this, 'render_dashboard_page']
118 );
119
120 // Essential SEO page (React tabbed interface)
121 $this->pages['essential_seo'] = add_submenu_page(
122 'thinkrank',
123 __('Essential SEO', 'thinkrank'),
124 __('Essential SEO', 'thinkrank'),
125 'manage_options',
126 'thinkrank-essential-seo',
127 [$this, 'render_essential_seo_page']
128 );
129
130 // AI Tools page
131 $this->pages['ai_tools'] = add_submenu_page(
132 'thinkrank',
133 __('AI Tools', 'thinkrank'),
134 __('AI Tools', 'thinkrank'),
135 'edit_posts',
136 'thinkrank-ai-tools',
137 [$this, 'render_ai_tools_page']
138 );
139
140 // Settings page
141 $this->pages['settings'] = add_submenu_page(
142 'thinkrank',
143 __('Settings', 'thinkrank'),
144 __('Settings', 'thinkrank'),
145 'manage_options',
146 'thinkrank-settings',
147 [$this, 'render_settings_page']
148 );
149
150 // Usage Analytics page
151 $this->pages['analytics'] = add_submenu_page(
152 'thinkrank',
153 __('Usage Analytics', 'thinkrank'),
154 __('Usage Analytics', 'thinkrank'),
155 'edit_posts',
156 'thinkrank-analytics',
157 [$this, 'render_analytics_page']
158 );
159
160 // Hook for page-specific initialization
161 foreach ($this->pages as $page_hook) {
162 add_action("load-{$page_hook}", [$this, 'load_admin_page']);
163 }
164 }
165
166 /**
167 * Enqueue admin scripts and styles
168 *
169 * APPROACH: Manual enqueuing with disabled webpack code splitting
170 * - All dependencies bundled into main admin.js (677KB)
171 * - Chart.js separated into charts.js (138KB) for performance
172 * - No dynamic chunks - predictable loading order
173 *
174 * @param string $hook_suffix Current admin page hook
175 * @return void
176 */
177 public function enqueue_admin_scripts(string $hook_suffix): void {
178 // Only load on our admin pages
179 if (!in_array($hook_suffix, $this->pages, true)) {
180 return;
181 }
182
183 // Get asset files for cache busting
184 $admin_asset_file = THINKRANK_PLUGIN_DIR . 'assets/admin.asset.php';
185 $admin_asset_data = file_exists($admin_asset_file) ? include $admin_asset_file : [
186 'dependencies' => [],
187 'version' => THINKRANK_VERSION,
188 ];
189
190 // Enqueue Chart.js bundle only on pages that render charts (Analytics and Essential SEO Performance)
191 $charts_asset_file = THINKRANK_PLUGIN_DIR . 'assets/charts.asset.php';
192 $should_enqueue_charts = in_array($hook_suffix, [
193 $this->pages['analytics'] ?? '',
194 $this->pages['essential_seo'] ?? '',
195 ], true);
196
197 if ($should_enqueue_charts && file_exists($charts_asset_file)) {
198 $charts_asset_data = include $charts_asset_file;
199 wp_enqueue_script(
200 'thinkrank-charts',
201 THINKRANK_PLUGIN_URL . 'assets/charts.js',
202 $charts_asset_data['dependencies'],
203 $charts_asset_data['version'],
204 true
205 );
206 // Add charts as dependency for admin script to ensure registration before use
207 $admin_dependencies = array_merge($admin_asset_data['dependencies'], ['thinkrank-charts']);
208 } else {
209 // Do not load charts on pages that don't need it
210 $admin_dependencies = $admin_asset_data['dependencies'];
211 }
212
213 wp_enqueue_script(
214 'thinkrank-admin',
215 THINKRANK_PLUGIN_URL . 'assets/admin.js',
216 $admin_dependencies,
217 $admin_asset_data['version'],
218 true
219 );
220
221 // Add defer attribute for better performance
222 wp_script_add_data('thinkrank-admin', 'defer', true);
223
224 // Enqueue admin styles
225 wp_enqueue_style(
226 'thinkrank-admin',
227 THINKRANK_PLUGIN_URL . 'assets/admin.css',
228 ['wp-components'],
229 $admin_asset_data['version']
230 );
231
232 // Enqueue WordPress media library for MediaPicker component
233 wp_enqueue_media();
234
235 // Localize script with data
236 wp_localize_script('thinkrank-admin', 'thinkrankAdmin', [
237 'apiUrl' => rest_url('thinkrank/v1/'),
238 'nonce' => wp_create_nonce('wp_rest'),
239 'restNonce' => wp_create_nonce('wp_rest'),
240 'adminNonce' => wp_create_nonce('thinkrank_admin'),
241 'currentUser' => wp_get_current_user()->ID,
242 'capabilities' => $this->get_user_capabilities(),
243 'settings' => $this->get_admin_settings(),
244 'i18n' => $this->get_i18n_strings(),
245 'isAdmin' => current_user_can('manage_options'),
246 // Site information for default values
247 'siteName' => get_bloginfo('name'),
248 'siteDescription' => get_bloginfo('description'),
249 'siteUrl' => home_url(),
250 'adminEmail' => get_option('admin_email'),
251 ]);
252
253 // Add welcome notice dismissal script
254 if (get_option('thinkrank_show_welcome') && !$this->has_api_key_configured()) {
255 wp_add_inline_script('thinkrank-admin', '
256 jQuery(document).ready(function($) {
257 $(".thinkrank-dismiss-welcome").on("click", function(e) {
258 e.preventDefault();
259
260 $.post(ajaxurl, {
261 action: "thinkrank_dismiss_notice",
262 notice_type: "welcome",
263 nonce: thinkrankAdmin.adminNonce
264 }, function(response) {
265 $(".thinkrank-welcome-notice").fadeOut();
266 });
267 });
268 });
269 ');
270 }
271 }
272
273 /**
274 * Handle admin initialization
275 *
276 * @return void
277 */
278 public function handle_admin_init(): void {
279 // Show welcome screen for new installations (only if no API key configured)
280 if (get_option('thinkrank_show_welcome') && !$this->has_api_key_configured()) {
281 add_action('admin_notices', [$this, 'show_welcome_notice']);
282 }
283
284 // Check for plugin updates
285 $this->check_plugin_updates();
286 }
287
288 /**
289 * Load admin page
290 *
291 * @return void
292 */
293 public function load_admin_page(): void {
294 // Add help tabs
295 $this->add_help_tabs();
296
297 // Add screen options
298 $this->add_screen_options();
299 }
300
301 /**
302 * Render dashboard page
303 *
304 * @return void
305 */
306 public function render_dashboard_page(): void {
307 $this->render_admin_page('dashboard', [
308 'title' => __('ThinkRank Dashboard', 'thinkrank'),
309 'description' => __('AI-powered SEO optimization for WordPress', 'thinkrank'),
310 ]);
311 }
312
313 /**
314 * Render Essential SEO page
315 *
316 * @return void
317 */
318 public function render_essential_seo_page(): void {
319 $this->render_admin_page('essential-seo', [
320 'title' => __('Essential SEO', 'thinkrank'),
321 'description' => __('Configure your site-wide SEO settings with AI-powered optimization', 'thinkrank'),
322 ]);
323 }
324
325 /**
326 * Render AI Tools page
327 *
328 * @return void
329 */
330 public function render_ai_tools_page(): void {
331 $this->render_admin_page('ai-tools', [
332 'title' => __('AI Tools', 'thinkrank'),
333 'description' => __('AI-powered content tools including Content Planner and Metadata Generator', 'thinkrank'),
334 ]);
335 }
336
337 /**
338 * Render settings page
339 *
340 * @return void
341 */
342 public function render_settings_page(): void {
343 $this->render_admin_page('settings', [
344 'title' => __('ThinkRank Settings', 'thinkrank'),
345 'description' => __('Configure your AI SEO settings', 'thinkrank'),
346 ]);
347 }
348
349
350
351 /**
352 * Render usage analytics page
353 *
354 * @return void
355 */
356 public function render_analytics_page(): void {
357 $this->render_admin_page('analytics', [
358 'title' => __('Usage Analytics', 'thinkrank'),
359 'description' => __('Track your AI usage, costs, and plugin performance analytics', 'thinkrank'),
360 ]);
361 }
362
363 /**
364 * Render admin page template
365 *
366 * @param string $page Page identifier
367 * @param array $data Page data
368 * @return void
369 */
370 private function render_admin_page(string $page, array $data): void {
371 ?>
372 <div class="wrap">
373 <div id="thinkrank-<?php echo esc_attr($page); ?>" class="thinkrank-admin-page">
374 <div class="thinkrank-loading">
375 <div class="spinner is-active"></div>
376 <p><?php esc_html_e('Loading ThinkRank...', 'thinkrank'); ?></p>
377 </div>
378 </div>
379 </div>
380 <?php
381 }
382
383 /**
384 * Add meta boxes to post edit screens
385 *
386 * @return void
387 */
388 public function add_meta_boxes(): void {
389 $post_types = get_post_types(['public' => true]);
390
391 foreach ($post_types as $post_type) {
392 add_meta_box(
393 'thinkrank-seo',
394 __('ThinkRank SEO', 'thinkrank'),
395 [$this, 'render_seo_meta_box'],
396 $post_type,
397 'normal',
398 'high'
399 );
400 }
401 }
402
403 /**
404 * Render SEO meta box
405 *
406 * @param \WP_Post $post Current post object
407 * @return void
408 */
409 public function render_seo_meta_box(\WP_Post $post): void {
410 wp_nonce_field('thinkrank_meta_box', 'thinkrank_meta_box_nonce');
411
412 echo '<div id="thinkrank-meta-box" data-post-id="' . esc_attr($post->ID) . '">';
413 echo '<div class="thinkrank-loading"><div class="spinner is-active"></div></div>';
414 echo '</div>';
415 }
416
417 /**
418 * Save meta box data
419 *
420 * @param int $post_id Post ID
421 * @return void
422 */
423 public function save_meta_boxes(int $post_id): void {
424 // Verify nonce
425 if (!isset($_POST['thinkrank_meta_box_nonce'])) {
426 return;
427 }
428
429 $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_meta_box_nonce']));
430 if (!wp_verify_nonce($nonce, 'thinkrank_meta_box')) {
431 return;
432 }
433
434 // Check permissions
435 if (!current_user_can('edit_post', $post_id)) {
436 return;
437 }
438
439 // Save meta data (handled by AJAX in React components)
440 do_action('thinkrank_save_post_meta', $post_id, $_POST);
441 }
442
443 /**
444 * Show admin notices
445 *
446 * @return void
447 */
448 public function show_admin_notices(): void {
449 // Implementation will be added in next iteration
450 }
451
452 /**
453 * Show welcome notice
454 *
455 * @return void
456 */
457 public function show_welcome_notice(): void {
458 ?>
459 <div class="notice notice-success is-dismissible thinkrank-welcome-notice">
460 <h3><?php esc_html_e('Welcome to ThinkRank!', 'thinkrank'); ?></h3>
461 <p><?php esc_html_e('Thank you for installing ThinkRank. Get started by configuring your AI settings.', 'thinkrank'); ?></p>
462 <p>
463 <a href="<?php echo esc_url(admin_url('admin.php?page=thinkrank-settings')); ?>" class="button button-primary">
464 <?php esc_html_e('Configure Settings', 'thinkrank'); ?>
465 </a>
466 <a href="#" class="button thinkrank-dismiss-welcome">
467 <?php esc_html_e('Dismiss', 'thinkrank'); ?>
468 </a>
469 </p>
470 </div>
471 <?php
472 }
473
474 /**
475 * Dismiss notice via AJAX
476 *
477 * @return void
478 */
479 public function dismiss_notice(): void {
480 check_ajax_referer('thinkrank_admin', 'nonce');
481
482 $notice_type = sanitize_key($_POST['notice_type'] ?? '');
483
484 if ($notice_type === 'welcome') {
485 delete_option('thinkrank_show_welcome');
486 }
487
488 wp_die();
489 }
490
491 /**
492 * Check if API key is configured
493 *
494 * @return bool True if at least one API key is configured
495 */
496 private function has_api_key_configured(): bool {
497 $settings = new \ThinkRank\Core\Settings();
498 $openai_key = $settings->get('openai_api_key');
499 $claude_key = $settings->get('claude_api_key');
500
501 return !empty($openai_key) || !empty($claude_key);
502 }
503
504 /**
505 * Get menu icon
506 *
507 * @return string Menu icon
508 */
509 private function get_menu_icon(): string {
510 return 'data:image/svg+xml;base64,' . base64_encode(
511 '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
512 <g clipPath="url(#thinkrank-clip)">
513 <g filter="url(#thinkrank-shadow)">
514 <circle cx="14" cy="9.2" r="1.3" fill="#a7aaad"/>
515 </g>
516 <path d="M19.5 7v5.3c0 2.4 0 3.6-.5 4.5-.4.8-1 1.4-1.8 1.8-.9.5-2.1.5-4.5.5H7.3c-2.4 0-3.6 0-4.5-.5-.8-.4-1.4-1-1.8-1.8-.2-.3-.3-.7-.4-1.1.5-.4.9-.6 1.3-.7.5-.2.9-.2 1.4-.2.7.1 1.3.2 2 .3.7.1 1.4.2 2.1.1 1.5-.3 2.5-1 3.4-2 .5-.5.9-1 1.4-1.5.3-.3.6-.7.9-1 .3.1.6.2.9.2.9 0 1.7-.8 1.7-1.7 0-.2 0-.4-.1-.6.7-.4 1.4-.8 2.1-1.1.6-.3 1.2-.6 1.6-.8v.4zM12.5 0c2.4 0 3.6 0 4.5.5.8.4 1.4 1 1.8 1.8.4.7.5 1.6.5 3.1-.1 0-.2.1-.3.1-.5.2-1.1.5-1.8.8-.7.3-1.5.7-2.2 1.1-.3-.3-.7-.4-1.1-.4-.9 0-1.7.8-1.7 1.7 0 .3.1.6.3.9-.3.4-.6.7-.9 1-.5.6-.9 1.1-1.3 1.5-.9.9-1.8 1.5-3 1.7-.6.1-1.2.1-1.8 0-.6-.1-1.3-.3-2-.4-.6-.1-1.2-.1-1.8.1-.3.1-.7.3-1 .5 0-.6 0-1.4 0-2.3V7c0-2.4 0-3.6.5-4.5.4-.8 1-1.4 1.8-1.8C3.9 0 5.1 0 7.5 0h5zm-5.8 8.2c0-.1-.1-.1-.2 0l-.2.9c0 0 0 .1-.1.1l-.9.2c-.1 0-.1.1 0 .1l.9.2c0 0 .1 0 .1.1l.2.9c0 .1.1.1.2 0l.2-.9c0 0 0-.1.1-.1l.9-.2c.1 0 .1-.1 0-.1l-.9-.2c0 0-.1 0-.1-.1l-.2-.9zm8.8-.4c0 .1 0 .2 0 .3 0 .7-.6 1.3-1.3 1.3-.2 0-.4 0-.5-.1.1-.1.2-.2.3-.3.4-.4.9-.8 1.5-1.2zm-1.3-1c.3 0 .5.1.7.2-.6.4-1.1.8-1.5 1.2l-.1.1c-.1.1-.2.2-.3.3-.1-.2-.1-.4-.1-.6 0-.7.6-1.2 1.3-1.2zM8.6 2.5c-.1-.2-.4-.2-.4 0l-.4 1.4c0 .1-.1.1-.1.1L6.2 4.4c-.2.1-.2.4 0 .4l1.4.4c.1 0 .1.1.1.1l.4 1.4c.1.2.4.2.4 0l.4-1.4c0-.1.1-.1.1-.1l1.4-.4c.2-.1.2-.4 0-.4L8.9 4c-.1 0-.1-.1-.1-.1L8.6 2.5z" fill="#a7aaad"/>
517 </g>
518 <defs>
519 <filter id="thinkrank-shadow" x="11.2" y="7.2" width="5.6" height="5.6" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
520 <feFlood floodOpacity="0" result="BackgroundImageFix"/>
521 <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
522 <feOffset dy="0.7"/>
523 <feGaussianBlur stdDeviation="0.7"/>
524 <feComposite in2="hardAlpha" operator="out"/>
525 <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
526 <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
527 <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
528 </filter>
529 <clipPath id="thinkrank-clip">
530 <rect width="20" height="20" rx="5.5" fill="white"/>
531 </clipPath>
532 </defs>
533 </svg>'
534 );
535 }
536
537 /**
538 * Get user capabilities for current user
539 *
540 * @return array User capabilities
541 */
542 private function get_user_capabilities(): array {
543 return [
544 'manage_settings' => current_user_can('manage_options'),
545 'view_analytics' => current_user_can('edit_posts'),
546
547 'use_ai_features' => current_user_can('edit_posts'),
548 ];
549 }
550
551 /**
552 * Get admin settings for JavaScript
553 *
554 * @return array Admin settings
555 */
556 private function get_admin_settings(): array {
557 return [
558
559 'ai_provider' => $this->settings->get('ai_provider', 'openai'),
560 'cache_duration' => $this->settings->get('cache_duration', 3600),
561 ];
562 }
563
564 /**
565 * Get internationalization strings
566 *
567 * @return array I18n strings
568 */
569 private function get_i18n_strings(): array {
570 return [
571 'loading' => __('Loading...', 'thinkrank'),
572 'error' => __('An error occurred', 'thinkrank'),
573 'success' => __('Success!', 'thinkrank'),
574 'confirm' => __('Are you sure?', 'thinkrank'),
575 'cancel' => __('Cancel', 'thinkrank'),
576 'save' => __('Save', 'thinkrank'),
577 ];
578 }
579
580 /**
581 * Add help tabs
582 *
583 * @return void
584 */
585 private function add_help_tabs(): void {
586 $screen = get_current_screen();
587
588 $screen->add_help_tab([
589 'id' => 'thinkrank-overview',
590 'title' => __('Overview', 'thinkrank'),
591 'content' => '<p>' . __('ThinkRank.ai helps you optimize your content with AI-powered SEO suggestions.', 'thinkrank') . '</p>',
592 ]);
593
594 $screen->set_help_sidebar(
595 '<p><strong>' . __('For more information:', 'thinkrank') . '</strong></p>' .
596 '<p><a href="https://thinkrank.ai/docs" target="_blank">' . __('Documentation', 'thinkrank') . '</a></p>' .
597 '<p><a href="https://thinkrank.ai/support" target="_blank">' . __('Support', 'thinkrank') . '</a></p>'
598 );
599 }
600
601 /**
602 * Add screen options
603 *
604 * @return void
605 */
606 private function add_screen_options(): void {
607 // Screen options will be added as needed
608 }
609
610 /**
611 * Check for plugin updates
612 *
613 * @return void
614 */
615 private function check_plugin_updates(): void {
616 $current_version = get_option('thinkrank_version');
617
618 if (version_compare($current_version, THINKRANK_VERSION, '<')) {
619 // Handle plugin update
620 do_action('thinkrank_plugin_updated', $current_version, THINKRANK_VERSION);
621 update_option('thinkrank_version', THINKRANK_VERSION);
622 }
623 }
624
625 /**
626 * Get admin styles
627 *
628 * @return string CSS styles
629 */
630 private function get_admin_styles(): string {
631 return '
632 .thinkrank-loading {
633 display: flex;
634 flex-direction: column;
635 align-items: center;
636 justify-content: center;
637 padding: 40px 20px;
638 text-align: center;
639 }
640
641 .thinkrank-loading .spinner {
642 margin-bottom: 16px;
643 }
644
645 .thinkrank-loading p {
646 margin: 0;
647 color: #666;
648 font-size: 14px;
649 }
650
651 .thinkrank-app {
652 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
653 }
654
655 .thinkrank-header {
656 display: flex;
657 justify-content: space-between;
658 align-items: center;
659 padding: 20px 0;
660 border-bottom: 1px solid #ddd;
661 margin-bottom: 20px;
662 }
663
664 .thinkrank-header h1 {
665 margin: 0;
666 font-size: 24px;
667 font-weight: 600;
668 }
669
670 .thinkrank-header .version {
671 font-size: 12px;
672 color: #666;
673 font-weight: normal;
674 margin-left: 8px;
675 }
676
677 .thinkrank-header .tagline {
678 margin: 4px 0 0 0;
679 color: #666;
680 font-size: 14px;
681 }
682
683
684
685 .thinkrank-dashboard-stats {
686 display: grid;
687 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
688 gap: 20px;
689 margin-top: 20px;
690 }
691
692 .stat-card {
693 background: #f9f9f9;
694 padding: 20px;
695 border-radius: 8px;
696 text-align: center;
697 border: 1px solid #ddd;
698 }
699
700 .stat-card h3 {
701 margin: 0 0 10px 0;
702 font-size: 14px;
703 color: #666;
704 text-transform: uppercase;
705 letter-spacing: 0.5px;
706 }
707
708 .stat-number {
709 font-size: 32px;
710 font-weight: 700;
711 color: #0073aa;
712 margin: 0;
713 line-height: 1;
714 }
715
716 .stat-label {
717 margin: 4px 0 0 0;
718 font-size: 12px;
719 color: #666;
720 }
721
722 .thinkrank-error {
723 padding: 20px;
724 }
725
726 .thinkrank-error .notice {
727 margin: 0;
728 }
729 ';
730 }
731
732
733 }
734