From 1ae831e1837edcf83416005746031e8467ed18c1 Mon Sep 17 00:00:00 2001 From: Terence Ong Date: Tue, 7 Apr 2026 09:45:12 +0700 Subject: [PATCH] feat: remove AI thumbnails, rewrite frontend with React (v1.3.0) Remove all AI thumbnail generation infrastructure (too expensive, poor results) and rewrite the public-facing article feed as a React SPA mounted via WordPress shortcode. Changes: - Remove thumbnail generation, OG scraping, and image download code from fetcher, LLM, and admin classes - Replace server-rendered PHP templates with React component (PeptideNewsFeed.jsx) using WordPress-bundled React/ReactDOM - Add Babel build pipeline for JSX transpilation - Update REST API to serve ai_summary instead of thumbnail fields - Redesign public CSS: clean card layout, loading skeletons, dark mode, responsive, no thumbnail imagery - Remove dead admin settings (llm_image_model, thumbnail_fallback) - Fix call_openrouter() visibility for Content Filter cross-class use - Bump version to 1.3.0 Co-Authored-By: Claude Opus 4.6 --- admin/class-peptide-news-admin.php | 47 +- admin/partials/articles-list.php | 24 +- includes/class-peptide-news-fetcher.php | 435 +-------- includes/class-peptide-news-llm.php | 254 +---- includes/class-peptide-news-rest-api.php | 4 +- includes/class-peptide-news.php | 1 - package-lock.json | 1096 ++++++++++++++++++++++ package.json | 28 + peptide-news-plugin.php | 4 +- public/class-peptide-news-public.php | 175 +--- public/css/public-style.css | 395 +++++--- public/js/peptide-news-feed.js | 367 ++++++++ public/js/public-script.js | 82 +- public/react-app/PeptideNewsFeed.jsx | 374 ++++++++ 14 files changed, 2194 insertions(+), 1092 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/js/peptide-news-feed.js create mode 100644 public/react-app/PeptideNewsFeed.jsx diff --git a/admin/class-peptide-news-admin.php b/admin/class-peptide-news-admin.php index c327f93..177dbd8 100644 --- a/admin/class-peptide-news-admin.php +++ b/admin/class-peptide-news-admin.php @@ -211,7 +211,7 @@ class Peptide_News_Admin { $this->add_setting( 'openrouter_api_key', __( 'OpenRouter API Key', 'peptide-news' ), 'render_openrouter_key_field', 'peptide_news_llm_section' ); $this->add_setting( 'llm_keywords_model', __( 'Keywords Model', 'peptide-news' ), 'render_llm_keywords_model_field', 'peptide_news_llm_section' ); $this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' ); - $this->add_setting( 'llm_image_model', __( 'Image Generation Model', 'peptide-news' ), 'render_llm_image_model_field', 'peptide_news_llm_section' ); + $this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' ); } @@ -265,9 +265,7 @@ class Peptide_News_Admin { 'openrouter_api_key' => 'sanitize_text_field', 'llm_keywords_model' => array( $this, 'sanitize_model_id' ), 'llm_summary_model' => array( $this, 'sanitize_model_id' ), - 'llm_image_model' => array( $this, 'sanitize_model_id' ), 'llm_max_articles' => 'absint', - 'thumbnail_fallback' => 'esc_url_raw', ); return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field'; @@ -532,15 +530,6 @@ class Peptide_News_Admin { echo '

' . esc_html__( 'OpenRouter model ID for article summarization (e.g., google/gemini-2.0-flash-001, anthropic/claude-3.5-sonnet).', 'peptide-news' ) . '

'; } - public function render_llm_image_model_field() { - $value = get_option( 'peptide_news_llm_image_model', '' ); - printf( - '', - esc_attr( $value ) - ); - echo '

' . esc_html__( 'OpenRouter model ID for AI thumbnail generation (e.g., openai/dall-e-3, stabilityai/stable-diffusion-xl). Leave blank to disable AI thumbnails.', 'peptide-news' ) . '

'; - } - public function render_llm_max_articles_field() { $value = get_option( 'peptide_news_llm_max_articles', 10 ); printf( @@ -579,13 +568,6 @@ class Peptide_News_Admin { echo ''; echo ''; - // Backfill Thumbnails button. - echo '
'; - echo ''; - echo ''; - echo '
'; ?> ID - - + + - + - - + @@ -72,21 +71,6 @@ $total_pages = ceil( $total / $per_page ); published_at ) ) ); ?> fetched_at ) ) ); ?> - - thumbnail_url ) && '_no_image' !== $article->thumbnail_url ) { - $thumb_src = $article->thumbnail_url; - } elseif ( ! empty( $article->thumbnail_local ) ) { - $upload_dir = wp_upload_dir(); - $thumb_src = $upload_dir['baseurl'] . '/' . $article->thumbnail_local; - } - if ( $thumb_src ) : ?> - - - - - is_active ? '✓' : '✗'; ?> diff --git a/includes/class-peptide-news-fetcher.php b/includes/class-peptide-news-fetcher.php index c8d6a7a..2f33708 100644 --- a/includes/class-peptide-news-fetcher.php +++ b/includes/class-peptide-news-fetcher.php @@ -89,23 +89,15 @@ class Peptide_News_Fetcher { 'new_stored' => $stored, ) ); - // Scrape OG images for articles missing thumbnails. - $og_scraped = $this->scrape_missing_thumbnails(); - // Run AI analysis on newly fetched articles (keywords + summary). if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) { $llm_batch_size = absint( get_option( 'peptide_news_llm_max_articles', 10 ) ); $llm_processed = Peptide_News_LLM::process_unanalyzed( $llm_batch_size ); - // Generate AI thumbnails for articles still missing images. - $ai_thumbs = Peptide_News_LLM::generate_missing_thumbnails( $llm_batch_size ); - // Append LLM stats to the fetch log. $fetch_log = get_option( 'peptide_news_last_fetch' ); if ( is_array( $fetch_log ) ) { - $fetch_log['ai_processed'] = $llm_processed; - $fetch_log['og_scraped'] = $og_scraped; - $fetch_log['ai_thumbnails'] = $ai_thumbs; + $fetch_log['ai_processed'] = $llm_processed; update_option( 'peptide_news_last_fetch', $fetch_log ); } } @@ -143,19 +135,6 @@ class Peptide_News_Fetcher { $items = $feed->get_items( 0, $max_items ); foreach ( $items as $item ) { - $thumbnail = ''; - - // Try enclosure first. - $enclosure = $item->get_enclosure(); - if ( $enclosure && $enclosure->get_link() ) { - $thumbnail = $enclosure->get_link(); - } - - // Try media:thumbnail or media:content. - if ( empty( $thumbnail ) ) { - $thumbnail = $this->extract_image_from_content( $item->get_content() ); - } - $pub_date = $item->get_date( 'Y-m-d H:i:s' ); if ( empty( $pub_date ) ) { $pub_date = current_time( 'mysql' ); @@ -171,7 +150,7 @@ class Peptide_News_Fetcher { 'excerpt' => wp_trim_words( wp_strip_all_tags( $item->get_description() ), 40 ), 'content' => wp_kses_post( $item->get_content() ), 'author' => sanitize_text_field( $item->get_author() ? $item->get_author()->get_name() : '' ), - 'thumbnail_url' => esc_url_raw( $thumbnail ), + 'thumbnail_url' => '', 'published_at' => $pub_date, 'categories' => $this->extract_categories( $item ), 'tags' => '', @@ -230,7 +209,7 @@ class Peptide_News_Fetcher { 'excerpt' => wp_trim_words( sanitize_text_field( $item['description'] ?? '' ), 40 ), 'content' => wp_kses_post( $item['content'] ?? '' ), 'author' => sanitize_text_field( $item['author'] ?? '' ), - 'thumbnail_url' => esc_url_raw( $item['urlToImage'] ?? '' ), + 'thumbnail_url' => '', 'published_at' => $pub_date, 'categories' => '', 'tags' => '', @@ -303,270 +282,6 @@ class Peptide_News_Fetcher { ) ); } - /** - * Scrape Open Graph images for articles that have no thumbnail. - * - * Fetches the article URL and extracts og:image, twitter:image, - * or other meta image tags. - * - * @return int Number of articles updated with scraped thumbnails. - */ - private function scrape_missing_thumbnails() { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_articles'; - $articles = $wpdb->get_results( - "SELECT id, source, source_url, title - FROM {$table} - WHERE is_active = 1 - AND ( thumbnail_url = '' OR thumbnail_url IS NULL ) - ORDER BY fetched_at DESC - LIMIT 20" - ); - - if ( empty( $articles ) ) { - return 0; - } - - $aggregators = array( 'news.google.com', 'news.yahoo.com', 'msn.com' ); - $updated = 0; - - foreach ( $articles as $article ) { - $domain = $this->extract_domain( $article->source_url ); - - // Skip aggregator URLs — they can't be scraped for OG images. - // These will be handled by AI image generation instead. - if ( in_array( $domain, $aggregators, true ) ) { - $wpdb->update( - $table, - array( 'thumbnail_url' => '_no_image' ), - array( 'id' => $article->id ), - array( '%s' ), - array( '%d' ) - ); - continue; - } - - // Direct OG scrape for non-aggregator URLs. - $image_url = $this->scrape_og_image( $article->source_url ); - - if ( ! empty( $image_url ) ) { - $local_path = $this->download_thumbnail( $image_url, $article->id, $article->title ); - - $update_data = array( 'thumbnail_url' => $image_url ); - if ( $local_path ) { - $update_data['thumbnail_local'] = $local_path; - } - - $wpdb->update( $table, $update_data, array( 'id' => $article->id ) ); - $updated++; - } else { - $wpdb->update( - $table, - array( 'thumbnail_url' => '_no_image' ), - array( 'id' => $article->id ), - array( '%s' ), - array( '%d' ) - ); - } - - usleep( 300000 ); - } - - if ( $updated > 0 ) { - $this->clear_article_cache(); - } - - return $updated; - } - - /** - * Scrape Open Graph / meta image tags from a URL. - * - * @param string $url The article URL to scrape. - * @return string Image URL or empty string. - */ - private function scrape_og_image( $url ) { - if ( empty( $url ) ) { - return ''; - } - - $response = wp_remote_get( $url, array( - 'timeout' => 15, - 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0)', - 'headers' => array( - 'Accept' => 'text/html', - ), - // Only fetch the first ~200KB for efficiency. - 'stream' => false, - 'limit_response_size' => 200000, - ) ); - - if ( is_wp_error( $response ) ) { - $this->log_error( 'OG scrape failed for ' . $url . ': ' . $response->get_error_message() ); - return ''; - } - - $status = wp_remote_retrieve_response_code( $response ); - if ( $status < 200 || $status >= 400 ) { - return ''; - } - - $html = wp_remote_retrieve_body( $response ); - - if ( empty( $html ) ) { - return ''; - } - - // Try meta tags in priority order. - $patterns = array( - // Open Graph image. - '/]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']/is', - '/]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']/is', - // Twitter card image. - '/]+name=["\']twitter:image["\'][^>]+content=["\']([^"\']+)["\']/is', - '/]+content=["\']([^"\']+)["\'][^>]+name=["\']twitter:image["\']/is', - // Schema.org image. - '/]+itemprop=["\']image["\'][^>]+content=["\']([^"\']+)["\']/is', - ); - - foreach ( $patterns as $pattern ) { - if ( preg_match( $pattern, $html, $matches ) ) { - $image_url = trim( $matches[1] ); - - // Validate it looks like an image URL. - if ( $this->is_valid_image_url( $image_url ) ) { - return esc_url_raw( $image_url ); - } - } - } - - return ''; - } - - /** - * Check if a URL appears to be a valid image. - * - * @param string $url - * @return bool - */ - private function is_valid_image_url( $url ) { - if ( empty( $url ) || strlen( $url ) < 10 ) { - return false; - } - - // Must start with http(s). - if ( ! preg_match( '/^https?:\/\//i', $url ) ) { - return false; - } - - // Reject obvious non-image URLs (tracking pixels, spacers, etc.) - $blocklist = array( '1x1', 'pixel', 'spacer', 'blank', 'transparent', 'data:image' ); - $url_lower = strtolower( $url ); - foreach ( $blocklist as $blocked ) { - if ( strpos( $url_lower, $blocked ) !== false ) { - return false; - } - } - - return true; - } - - /** - * Download an image and store it in the WP uploads directory. - * - * @param string $image_url External image URL. - * @param int $article_id Article ID for unique naming. - * @param string $title Article title for alt text / file naming. - * @return string|false Local relative path on success, false on failure. - */ - private function download_thumbnail( $image_url, $article_id, $title = '' ) { - // SSRF prevention: validate URL before fetching. - if ( ! wp_http_validate_url( $image_url ) ) { - $this->log_error( 'Invalid image URL (SSRF protection) for article ' . $article_id . ': ' . $image_url ); - return false; - } - - $upload_dir = wp_upload_dir(); - $target_dir = $upload_dir['basedir'] . '/peptide-news-thumbs'; - - // Create directory if it doesn't exist. - if ( ! file_exists( $target_dir ) ) { - wp_mkdir_p( $target_dir ); - } - - $max_file_size = 5 * 1024 * 1024; // 5 MB. - - $response = wp_remote_get( $image_url, array( - 'timeout' => 20, - 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0)', - 'limit_response_size' => $max_file_size, - ) ); - - if ( is_wp_error( $response ) ) { - $this->log_error( 'Thumbnail download failed for article ' . $article_id . ': ' . $response->get_error_message() ); - return false; - } - - $body = wp_remote_retrieve_body( $response ); - if ( empty( $body ) || strlen( $body ) < 1000 || strlen( $body ) > $max_file_size ) { - // Too small to be a real image, or exceeds size limit. - return false; - } - - $content_type = wp_remote_retrieve_header( $response, 'content-type' ); - $extension = $this->get_extension_from_content_type( $content_type ); - - if ( ! $extension ) { - // Try to detect from the image data. - $finfo = new finfo( FILEINFO_MIME_TYPE ); - $mime = $finfo->buffer( $body ); - $extension = $this->get_extension_from_content_type( $mime ); - } - - if ( ! $extension ) { - return false; - } - - $filename = 'pn-thumb-' . $article_id . '.' . $extension; - $filepath = $target_dir . '/' . $filename; - - // Write the file. - $result = file_put_contents( $filepath, $body ); - - if ( false === $result ) { - $this->log_error( 'Failed to write thumbnail file: ' . $filepath ); - return false; - } - - // Set secure file permissions. - chmod( $filepath, 0644 ); - - // Return the relative path from uploads base. - return 'peptide-news-thumbs/' . $filename; - } - - /** - * Map content type to file extension. - * - * @param string $content_type - * @return string|false - */ - private function get_extension_from_content_type( $content_type ) { - $map = array( - 'image/jpeg' => 'jpg', - 'image/jpg' => 'jpg', - 'image/png' => 'png', - 'image/gif' => 'gif', - 'image/webp' => 'webp', - 'image/svg+xml' => 'svg', - ); - - $content_type = strtolower( trim( explode( ';', $content_type )[0] ) ); - - return isset( $map[ $content_type ] ) ? $map[ $content_type ] : false; - } - /** * Clear all article transient caches. */ @@ -583,29 +298,6 @@ class Peptide_News_Fetcher { ); } - /** - * Try to extract an image URL from HTML content. - * - * @param string $content - * @return string - */ - private function extract_image_from_content( $content ) { - if ( empty( $content ) ) { - return ''; - } - - if ( preg_match( '/]+src=["\']([^"\']+)["\']/', $content, $matches ) ) { - return $matches[1]; - } - - // Check for media:content or media:thumbnail. - if ( preg_match( '/url=["\']([^"\']+)["\']/', $content, $matches ) ) { - return $matches[1]; - } - - return ''; - } - /** * Extract categories from a SimplePie item. * @@ -736,127 +428,6 @@ class Peptide_News_Fetcher { return ! empty( $final_url ) ? $final_url : false; } - - /** - * Backfill thumbnails for existing articles that have none. - * - * Attempts OG scraping for non-aggregator URLs, then uses AI image - * generation for any remaining articles without thumbnails. - * - * @return array Summary with counts and diagnostic samples. - */ - public function backfill_article_thumbnails() { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_articles'; - $aggregators = array( 'news.google.com', 'news.yahoo.com', 'msn.com' ); - - // Reset sentinels so we retry everything. - $wpdb->query( - "UPDATE {$table} - SET thumbnail_url = '' - WHERE thumbnail_url = '_no_image' - AND is_active = 1" - ); - - $articles = $wpdb->get_results( - "SELECT id, source, source_url, title, excerpt, ai_summary, tags, categories - FROM {$table} - WHERE is_active = 1 - AND ( thumbnail_url = '' OR thumbnail_url IS NULL ) - AND ( thumbnail_local = '' OR thumbnail_local IS NULL ) - ORDER BY fetched_at DESC - LIMIT 20" - ); - - $result = array( - 'total' => count( $articles ), - 'scraped' => 0, - 'generated' => 0, - 'failed' => 0, - 'samples' => array(), - ); - - if ( empty( $articles ) ) { - return $result; - } - - // Phase 1: OG scrape non-aggregator URLs. - foreach ( $articles as $article ) { - $domain = $this->extract_domain( $article->source_url ); - if ( in_array( $domain, $aggregators, true ) ) { - continue; // Skip — will be handled by AI generation. - } - - $image_url = $this->scrape_og_image( $article->source_url ); - if ( ! empty( $image_url ) ) { - $local_path = $this->download_thumbnail( $image_url, $article->id, $article->title ); - $update_data = array( 'thumbnail_url' => $image_url ); - if ( $local_path ) { - $update_data['thumbnail_local'] = $local_path; - } - $wpdb->update( $table, $update_data, array( 'id' => $article->id ) ); - $result['scraped']++; - - if ( count( $result['samples'] ) < 5 ) { - $result['samples'][] = array( - 'id' => $article->id, - 'title' => mb_substr( $article->title, 0, 60 ), - 'method' => 'og_scrape', - 'status' => 'ok', - ); - } - } - - usleep( 300000 ); - } - - // Phase 2: AI generation for articles still missing thumbnails. - if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) { - $ai_generated = Peptide_News_LLM::generate_missing_thumbnails( 20 ); - $result['generated'] = $ai_generated; - } - - // Count remaining failures. - $still_missing = $wpdb->get_var( - "SELECT COUNT(*) - FROM {$table} - WHERE is_active = 1 - AND ( thumbnail_url = '' OR thumbnail_url IS NULL ) - AND ( thumbnail_local = '' OR thumbnail_local IS NULL )" - ); - $result['failed'] = (int) $still_missing; - - // Mark remaining as '_no_image' so cron doesn't retry every cycle. - $wpdb->query( - "UPDATE {$table} - SET thumbnail_url = '_no_image' - WHERE is_active = 1 - AND ( thumbnail_url = '' OR thumbnail_url IS NULL ) - AND ( thumbnail_local = '' OR thumbnail_local IS NULL )" - ); - - if ( $result['scraped'] > 0 || $result['generated'] > 0 ) { - $this->clear_article_cache(); - } - - return $result; - } - - /** - * AJAX handler for the thumbnail backfill action. - */ - public function ajax_backfill_thumbnails() { - check_ajax_referer( 'peptide_news_admin', 'nonce' ); - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( 'Unauthorized', 403 ); - } - - $result = $this->backfill_article_thumbnails(); - wp_send_json_success( $result ); - } - /** * Backfill source names for existing articles that show an aggregator domain. * diff --git a/includes/class-peptide-news-llm.php b/includes/class-peptide-news-llm.php index 3d1e4ff..c8bfa89 100644 --- a/includes/class-peptide-news-llm.php +++ b/includes/class-peptide-news-llm.php @@ -233,7 +233,7 @@ class Peptide_News_LLM { * @param string $prompt * @return string|WP_Error The response text or an error. */ - private static function call_openrouter( $api_key, $model, $prompt ) { + public static function call_openrouter( $api_key, $model, $prompt ) { $body = array( 'model' => $model, 'messages' => array( @@ -370,258 +370,6 @@ class Peptide_News_LLM { } } - /** - * Generate AI thumbnails for articles that still have no image - * after OG scraping. Uses an image generation model via OpenRouter. - * - * @param int $batch_size Max articles to process per run. - * @return int Number of thumbnails generated. - */ - public static function generate_missing_thumbnails( $batch_size = 10 ) { - if ( ! self::is_enabled() ) { - return 0; - } - - $image_model = get_option( 'peptide_news_llm_image_model', '' ); - if ( empty( $image_model ) ) { - return 0; - } - - global $wpdb; - $table = $wpdb->prefix . 'peptide_news_articles'; - - // Find active articles with no usable thumbnail (including _no_image sentinel). - $articles = $wpdb->get_results( $wpdb->prepare( - "SELECT id, title, excerpt, ai_summary, tags, categories - FROM {$table} - WHERE is_active = 1 - AND ( thumbnail_url = '' OR thumbnail_url IS NULL OR thumbnail_url = '_no_image' ) - AND ( thumbnail_local = '' OR thumbnail_local IS NULL ) - ORDER BY fetched_at DESC - LIMIT %d", - $batch_size - ) ); - - if ( empty( $articles ) ) { - return 0; - } - - $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); - $generated = 0; - - foreach ( $articles as $article ) { - $image_data = self::generate_article_thumbnail( $api_key, $image_model, $article ); - - if ( $image_data ) { - // Save the image locally. - $local_path = self::save_generated_image( $image_data, $article->id ); - - if ( $local_path ) { - $wpdb->update( - $table, - array( 'thumbnail_local' => $local_path ), - array( 'id' => $article->id ), - array( '%s' ), - array( '%d' ) - ); - $generated++; - } - } - - // Respect rate limits. - usleep( 1000000 ); // 1 second between generations. - } - - if ( $generated > 0 ) { - self::clear_article_cache(); - } - - return $generated; - } - - /** - * Generate a thumbnail image for a single article via OpenRouter. - * - * @param string $api_key - * @param string $model Image generation model ID. - * @param object $article Database row. - * @return string|false Base64-encoded image data, or false. - */ - private static function generate_article_thumbnail( $api_key, $model, $article ) { - $prompt = self::build_image_prompt( $article ); - - $body = array( - 'model' => $model, - 'messages' => array( - array( - 'role' => 'user', - 'content' => $prompt, - ), - ), - ); - - $response = wp_remote_post( self::API_URL, array( - 'timeout' => 60, - 'headers' => array( - 'Authorization' => 'Bearer ' . $api_key, - 'Content-Type' => 'application/json', - 'HTTP-Referer' => home_url(), - 'X-Title' => 'Peptide News Aggregator', - ), - 'body' => wp_json_encode( $body ), - ) ); - - if ( is_wp_error( $response ) ) { - self::log_error( 'Image generation failed for article ' . $article->id . ': ' . $response->get_error_message() ); - return false; - } - - $status = wp_remote_retrieve_response_code( $response ); - $data = json_decode( wp_remote_retrieve_body( $response ), true ); - - if ( $status < 200 || $status >= 300 ) { - $error = isset( $data['error']['message'] ) ? $data['error']['message'] : 'HTTP ' . $status; - self::log_error( 'Image generation HTTP error for article ' . $article->id . ': ' . $error ); - return false; - } - - // Extract the image data from the response. - // OpenRouter image models may return base64 data in various formats. - if ( ! empty( $data['choices'][0]['message']['content'] ) ) { - $content = $data['choices'][0]['message']['content']; - - // If content is an array (multimodal), look for image parts. - if ( is_array( $content ) ) { - foreach ( $content as $part ) { - if ( isset( $part['type'] ) && 'image_url' === $part['type'] - && isset( $part['image_url'] ) && is_array( $part['image_url'] ) - && isset( $part['image_url']['url'] ) && is_string( $part['image_url']['url'] ) ) { - $url = $part['image_url']['url']; - if ( strpos( $url, 'data:image' ) === 0 ) { - // Extract base64 from data URL. - $base64 = preg_replace( '/^data:image\/\w+;base64,/', '', $url ); - $decoded = base64_decode( $base64, true ); - if ( false === $decoded ) { - self::log_error( 'Invalid base64 image data for article.' ); - return false; - } - return $decoded; - } - } - } - } - - // If it's a URL string, download it (with SSRF protection). - if ( is_string( $content ) && preg_match( '/^https?:\/\//', $content ) ) { - $content_url = trim( $content ); - if ( ! wp_http_validate_url( $content_url ) ) { - self::log_error( 'Invalid image URL (SSRF protection) for article ' . $article->id ); - return false; - } - $img_response = wp_remote_get( $content_url, array( - 'timeout' => 30, - 'limit_response_size' => 5 * 1024 * 1024, - ) ); - if ( ! is_wp_error( $img_response ) ) { - return wp_remote_retrieve_body( $img_response ); - } - } - } - - // Check for data array format (DALL-E style). - if ( ! empty( $data['data'][0]['b64_json'] ) ) { - $decoded = base64_decode( $data['data'][0]['b64_json'], true ); - if ( false === $decoded ) { - self::log_error( 'Invalid base64 in DALL-E response for article ' . $article->id ); - return false; - } - return $decoded; - } - - if ( ! empty( $data['data'][0]['url'] ) ) { - $dalle_url = $data['data'][0]['url']; - if ( wp_http_validate_url( $dalle_url ) ) { - $img_response = wp_remote_get( $dalle_url, array( - 'timeout' => 30, - 'limit_response_size' => 5 * 1024 * 1024, - ) ); - if ( ! is_wp_error( $img_response ) ) { - return wp_remote_retrieve_body( $img_response ); - } - } else { - self::log_error( 'Invalid DALL-E image URL (SSRF protection) for article ' . $article->id ); - } - } - - self::log_error( 'Could not extract image data from response for article ' . $article->id ); - return false; - } - - /** - * Build the prompt for AI image generation. - * - * @param object $article - * @return string - */ - private static function build_image_prompt( $article ) { - $summary = ! empty( $article->ai_summary ) ? $article->ai_summary : $article->excerpt; - $tags = ! empty( $article->tags ) ? $article->tags : ( $article->categories ?? '' ); - - return "Generate a professional, science-themed thumbnail image for a peptide research news article. " - . "The image should be modern, clean, and suitable for a news website card layout (16:9 aspect ratio). " - . "Use a scientific aesthetic: molecular structures, laboratory elements, or abstract biotech visuals. " - . "Do NOT include any text or words in the image.\n\n" - . "Article title: " . $article->title . "\n" - . ( ! empty( $summary ) ? "Summary: " . mb_substr( $summary, 0, 200 ) . "\n" : '' ) - . ( ! empty( $tags ) ? "Keywords: " . $tags . "\n" : '' ); - } - - /** - * Save a generated image to the WP uploads directory. - * - * @param string $image_data Raw image binary data. - * @param int $article_id - * @return string|false Relative path from uploads base, or false. - */ - private static function save_generated_image( $image_data, $article_id ) { - if ( empty( $image_data ) || strlen( $image_data ) < 1000 ) { - return false; - } - - $upload_dir = wp_upload_dir(); - $target_dir = $upload_dir['basedir'] . '/peptide-news-thumbs'; - - if ( ! file_exists( $target_dir ) ) { - wp_mkdir_p( $target_dir ); - } - - // Detect image type from data. - $finfo = new finfo( FILEINFO_MIME_TYPE ); - $mime = $finfo->buffer( $image_data ); - $ext_map = array( - 'image/png' => 'png', - 'image/jpeg' => 'jpg', - 'image/webp' => 'webp', - 'image/gif' => 'gif', - ); - $extension = isset( $ext_map[ $mime ] ) ? $ext_map[ $mime ] : 'png'; - - $filename = 'pn-ai-' . $article_id . '.' . $extension; - $filepath = $target_dir . '/' . $filename; - - $result = file_put_contents( $filepath, $image_data ); - - if ( false === $result ) { - self::log_error( 'Failed to save generated thumbnail: ' . $filepath ); - return false; - } - - // Set secure file permissions. - chmod( $filepath, 0644 ); - - return 'peptide-news-thumbs/' . $filename; - } - /** * Clear all article transient caches. */ diff --git a/includes/class-peptide-news-rest-api.php b/includes/class-peptide-news-rest-api.php index cc895f9..d907a2b 100644 --- a/includes/class-peptide-news-rest-api.php +++ b/includes/class-peptide-news-rest-api.php @@ -133,8 +133,8 @@ class Peptide_News_Rest_API { $table = $wpdb->prefix . 'peptide_news_articles'; $articles = $wpdb->get_results( $wpdb->prepare( - "SELECT id, source, source_url, title, excerpt, author, thumbnail_url, - thumbnail_local, published_at, categories, tags + "SELECT id, source, source_url, title, excerpt, ai_summary, author, + published_at, categories, tags FROM {$table} WHERE is_active = 1 ORDER BY published_at DESC diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php index 7b92530..7dee51b 100644 --- a/includes/class-peptide-news.php +++ b/includes/class-peptide-news.php @@ -81,7 +81,6 @@ class Peptide_News { $this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' ); $this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' ); $this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' ); - $this->loader->add_action( 'wp_ajax_peptide_news_backfill_thumbnails', $fetcher, 'ajax_backfill_thumbnails' ); } /** diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e81395a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1096 @@ +{ + "name": "repo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "repo", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@babel/cli": "^7.28.6", + "@babel/core": "^7.29.0", + "@babel/preset-react": "^7.28.5" + } + }, + "node_modules/@babel/cli": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.6.tgz", + "integrity": "sha512-6EUNcuBbNkj08Oj4gAZ+BUU8yLCgKzgVX4gaTh09Ya2C8ICM4P+G30g4m3akRxSYAp3A/gnWchrNst7px4/nUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.28", + "commander": "^6.2.0", + "convert-source-map": "^2.0.0", + "fs-readdir-recursive": "^1.1.0", + "glob": "^7.2.0", + "make-dir": "^2.1.0", + "slash": "^2.0.0" + }, + "bin": { + "babel": "bin/babel.js", + "babel-external-helpers": "bin/babel-external-helpers.js" + }, + "engines": { + "node": ">=6.9.0" + }, + "optionalDependencies": { + "@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3", + "chokidar": "^3.6.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", + "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.28.0", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nicolo-ribaudo/chokidar-2": { + "version": "2.1.8-no-fsevents.3", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", + "integrity": "sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001786", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", + "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-readdir-recursive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", + "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..07aee5c --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "repo", + "version": "1.0.0", + "description": "A WordPress plugin that aggregates and displays the latest peptide research news from multiple sources, with built-in click analytics and trend reporting.", + "main": "index.js", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://peptiderepo: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@github.com/peptiderepo/peptide-news-plugin.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/peptiderepo/peptide-news-plugin/issues" + }, + "homepage": "https://github.com/peptiderepo/peptide-news-plugin#readme", + "devDependencies": { + "@babel/cli": "^7.28.6", + "@babel/core": "^7.29.0", + "@babel/preset-react": "^7.28.5" + } +} diff --git a/peptide-news-plugin.php b/peptide-news-plugin.php index 39f7912..b8b100d 100644 --- a/peptide-news-plugin.php +++ b/peptide-news-plugin.php @@ -3,7 +3,7 @@ * Plugin Name: Peptide News Aggregator * Plugin URI: https://github.com/peptiderepo/peptide-news-plugin * Description: Aggregates and displays the latest peptide research news from multiple sources with click analytics and trend reporting. - * Version: 1.2.2 + * Version: 1.3.0 * Author: Peptide News Team * Author URI: https://github.com/peptiderepo * License: GPL-2.0+ @@ -22,7 +22,7 @@ if ( ! defined( 'WPINC' ) ) { /** * Current plugin version — follows SemVer. */ -define( 'PEPTIDE_NEWS_VERSION', '1.2.2' ); +define( 'PEPTIDE_NEWS_VERSION', '1.3.0' ); define( 'PEPTIDE_NEWS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PEPTIDE_NEWS_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); diff --git a/public/class-peptide-news-public.php b/public/class-peptide-news-public.php index caec701..b99a11a 100644 --- a/public/class-peptide-news-public.php +++ b/public/class-peptide-news-public.php @@ -2,10 +2,11 @@ /** * Public-facing functionality. * - * Registers the [peptide_news] shortcode, the sidebar widget, - * and handles AJAX click tracking. + * Registers the [peptide_news] shortcode which renders a React-powered + * news feed, the sidebar widget, and handles AJAX click tracking. * * @since 1.0.0 + * @since 2.0.0 Rewritten to use React frontend. */ class Peptide_News_Public { @@ -35,21 +36,19 @@ class Peptide_News_Public { /** * Register front-end JS (enqueued on demand by the shortcode/widget). + * + * Loads React (from WordPress core), the feed component, and + * passes configuration via wp_localize_script. */ public function enqueue_scripts() { + // React feed component (depends on WP-bundled React). wp_register_script( - $this->plugin_name, - PEPTIDE_NEWS_PLUGIN_URL . 'public/js/public-script.js', - array( 'jquery' ), + $this->plugin_name . '-feed', + PEPTIDE_NEWS_PLUGIN_URL . 'public/js/peptide-news-feed.js', + array( 'react', 'react-dom' ), $this->version, true ); - - wp_localize_script( $this->plugin_name, 'peptideNewsPublic', array( - 'ajax_url' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( 'peptide_news_track' ), - 'session_id' => $this->get_or_create_session_id(), - ) ); } /** @@ -69,111 +68,35 @@ class Peptide_News_Public { /** * Render the [peptide_news] shortcode. * + * Outputs a mount-point div for the React feed component and + * enqueues all required assets. Configuration is passed to JS + * via a localized script object. + * * @param array $atts Shortcode attributes. * @return string HTML output. */ public function render_shortcode( $atts ) { $atts = shortcode_atts( array( - 'count' => get_option( 'peptide_news_articles_count', 10 ), - 'layout' => 'list', // card | compact | list + 'count' => get_option( 'peptide_news_articles_count', 10 ), ), $atts, 'peptide_news' ); - $articles = $this->get_articles( absint( $atts['count'] ) ); - - if ( empty( $articles ) ) { - return '

' . esc_html__( 'No peptide news articles available yet. Check back soon!', 'peptide-news' ) . '

'; - } + $count = absint( $atts['count'] ); // Enqueue assets only when the shortcode is actually rendered. wp_enqueue_style( $this->plugin_name ); - wp_enqueue_script( $this->plugin_name ); + wp_enqueue_script( $this->plugin_name . '-feed' ); - $fallback_thumb = get_option( 'peptide_news_thumbnail_fallback', '' ); - $layout = sanitize_key( $atts['layout'] ); + // Pass configuration to the React component. + wp_localize_script( $this->plugin_name . '-feed', 'peptideNewsFeed', array( + 'restUrl' => esc_url_raw( rest_url( 'peptide-news/v1/' ) ), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'peptide_news_track' ), + 'sessionId' => $this->get_or_create_session_id(), + 'count' => $count, + ) ); - ob_start(); - ?> -
- thumbnail_url ) && '_no_image' !== $article->thumbnail_url ) { - $thumb = $article->thumbnail_url; - } elseif ( ! empty( $article->thumbnail_local ) ) { - $upload_dir = wp_upload_dir(); - $thumb = $upload_dir['baseurl'] . '/' . $article->thumbnail_local; - } - if ( empty( $thumb ) ) { - $thumb = $fallback_thumb; - } - $date = wp_date( 'M j, Y', strtotime( $article->published_at ) ); - ?> -
- -
- - <?php echo esc_attr( $article->title ); ?> - -
- - -
- - -

- - title ); ?> - -

- - ai_summary ) ? $article->ai_summary : ( $article->excerpt ?? '' ); - if ( ! empty( $display_text ) ) : ?> -

- -

- - - author ) ) : ?> - - author ); ?> - - - - tags ) ? $article->tags : ( $article->categories ?? '' ); - if ( ! empty( $tag_source ) ) : ?> -
- - - -
- -
-
- -
- '; } /** @@ -214,48 +137,9 @@ class Peptide_News_Public { } } - /** - * Get active articles from the database. - * - * @param int $count Number of articles. - * @return array - */ - private function get_articles( $count ) { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_articles'; - - // Use transient cache to avoid DB hits on every page load. - $cache_key = 'peptide_news_articles_' . $count; - $cached = get_transient( $cache_key ); - - if ( false !== $cached ) { - return $cached; - } - - $articles = $wpdb->get_results( $wpdb->prepare( - "SELECT id, source, source_url, title, excerpt, ai_summary, author, thumbnail_url, - thumbnail_local, published_at, categories, tags - FROM {$table} - WHERE is_active = 1 - ORDER BY published_at DESC - LIMIT %d", - $count - ) ); - - // Cache for 5 minutes. - set_transient( $cache_key, $articles, 5 * MINUTE_IN_SECONDS ); - - return $articles; - } - /** * Generate or retrieve a session ID for visitor tracking. * - * Sets the cookie in PHP if it doesn't exist so the value - * is consistent between the server-side localized script data - * and the client-side cookie. - * * @return string */ private function get_or_create_session_id() { @@ -265,7 +149,6 @@ class Peptide_News_Public { $session_id = wp_generate_uuid4(); - // Set a 30-minute session cookie (matches JS-side behaviour). if ( ! headers_sent() ) { setcookie( 'pn_session_id', @@ -274,7 +157,7 @@ class Peptide_News_Public { 'expires' => time() + 1800, 'path' => '/', 'secure' => is_ssl(), - 'httponly' => false, // JS needs read access for sendBeacon. + 'httponly' => false, 'samesite' => 'Lax', ) ); @@ -313,7 +196,7 @@ class Peptide_News_Widget extends WP_Widget { } $public = new Peptide_News_Public( 'peptide-news', PEPTIDE_NEWS_VERSION ); - echo $public->render_shortcode( array( 'count' => $count, 'layout' => 'compact' ) ); + echo $public->render_shortcode( array( 'count' => $count ) ); echo $args['after_widget']; } diff --git a/public/css/public-style.css b/public/css/public-style.css index 11fb011..29a4a5e 100644 --- a/public/css/public-style.css +++ b/public/css/public-style.css @@ -1,114 +1,79 @@ /** - * Peptide News Aggregator – Public Styles + * Peptide News Feed — Public Styles * - * Styles for the front-end display of peptide news articles. + * Clean, modern news feed layout without thumbnails. + * Focuses on readability and content hierarchy. * - * @since 1.0.0 - * @since 1.2.2 Rewritten for list layout with thumbnail left / text right. + * @since 2.0.0 */ /* ── Feed container ────────────────────────────────────────────── */ .pn-news-feed { + max-width: 760px; + margin: 0 auto; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + color: #1a1a2e; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +/* ── Articles list ─────────────────────────────────────────────── */ +.pn-articles-list { display: flex; flex-direction: column; gap: 0; - max-width: 900px; - margin: 0 auto; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; } -.pn-no-articles p { - text-align: center; - color: #666; - padding: 2rem 1rem; - font-size: 0.9375rem; -} - -/* ── Individual article row ────────────────────────────────────── */ +/* ── Individual article ────────────────────────────────────────── */ .pn-article { - display: flex; - flex-direction: row; - align-items: flex-start; - gap: 1.25rem; - padding: 1.25rem 0; - border-bottom: 1px solid #e8e8e8; + padding: 1.5rem 0; + border-bottom: 1px solid #e8eaed; transition: background-color 0.15s ease; } +.pn-article:first-child { + padding-top: 0; +} + .pn-article:last-child { border-bottom: none; } -.pn-article:hover { - background-color: #fafbfc; -} - -/* ── Thumbnail (left column, fixed width) ──────────────────────── */ -.pn-article-thumb { - flex: 0 0 160px; - width: 160px; - min-height: 100px; - overflow: hidden; - border-radius: 6px; -} - -.pn-article-thumb a { - display: block; - line-height: 0; -} - -.pn-article-thumb img { - width: 100%; - height: 110px; - object-fit: cover; - border-radius: 6px; - transition: transform 0.2s ease; -} - -.pn-article:hover .pn-article-thumb img { - transform: scale(1.03); -} - -/* ── Content (right column, flexible) ──────────────────────────── */ -.pn-article-content { - flex: 1; - min-width: 0; -} - /* ── Meta line (source · date) ─────────────────────────────────── */ .pn-article-meta { display: flex; align-items: center; gap: 0.4rem; - font-size: 0.8rem; - color: #777; - margin-bottom: 0.35rem; + margin-bottom: 0.375rem; + font-size: 0.8125rem; + color: #6b7280; line-height: 1.4; } .pn-source { font-weight: 600; - color: #555; + color: #4b5563; text-transform: uppercase; - letter-spacing: 0.025em; - font-size: 0.7rem; + letter-spacing: 0.03em; + font-size: 0.75rem; } .pn-separator { - color: #ccc; + color: #d1d5db; } .pn-date { - color: #888; + color: #9ca3af; } /* ── Title ─────────────────────────────────────────────────────── */ .pn-article-title { - font-size: 1.05rem; + font-size: 1.125rem; font-weight: 600; line-height: 1.4; - margin: 0 0 0.4rem; - color: #1a1a2e; + margin: 0 0 0.5rem; + color: #111827; } .pn-article-title a { @@ -118,23 +83,23 @@ } .pn-article-title a:hover { - color: #0073aa; + color: #2563eb; } -.pn-article-title a:focus, -.pn-article-thumb a:focus { - outline: 2px solid #0073aa; +.pn-article-title a:focus-visible { + outline: 2px solid #2563eb; outline-offset: 2px; + border-radius: 2px; } /* ── Excerpt / AI summary ──────────────────────────────────────── */ .pn-article-excerpt { - font-size: 0.875rem; - line-height: 1.6; - color: #555; - margin: 0 0 0.5rem; + font-size: 0.9375rem; + line-height: 1.65; + color: #4b5563; + margin: 0 0 0.625rem; display: -webkit-box; - -webkit-line-clamp: 2; + -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } @@ -142,9 +107,9 @@ /* ── Author ────────────────────────────────────────────────────── */ .pn-author { display: inline-block; - font-size: 0.775rem; - color: #888; - margin-bottom: 0.35rem; + font-size: 0.8125rem; + color: #9ca3af; + margin-bottom: 0.375rem; } .pn-author::before { @@ -156,60 +121,266 @@ .pn-tags { display: flex; flex-wrap: wrap; - gap: 0.35rem; - margin-top: 0.25rem; + gap: 0.375rem; + margin-top: 0.375rem; } .pn-tag { display: inline-block; - font-size: 0.7rem; - background: #f0f4f8; - color: #4a6fa5; - padding: 0.15rem 0.55rem; - border-radius: 12px; + font-size: 0.6875rem; + background: #eff6ff; + color: #1d4ed8; + padding: 0.1875rem 0.625rem; + border-radius: 100px; line-height: 1.5; font-weight: 500; + letter-spacing: 0.01em; } -/* ── Compact layout override (for widgets) ─────────────────────── */ -.pn-layout-compact .pn-article { - gap: 0.85rem; - padding: 0.75rem 0; +/* ── Loading skeleton ──────────────────────────────────────────── */ +.pn-loading { + display: flex; + flex-direction: column; + gap: 0; } -.pn-layout-compact .pn-article-thumb { - flex: 0 0 80px; - width: 80px; - min-height: 56px; +.pn-skeleton-card { + padding: 1.5rem 0; + border-bottom: 1px solid #e8eaed; } -.pn-layout-compact .pn-article-thumb img { - height: 56px; +.pn-skeleton-card:first-child { + padding-top: 0; } -.pn-layout-compact .pn-article-title { - font-size: 0.9rem; +.pn-skeleton-line { + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); + background-size: 200% 100%; + animation: pn-shimmer 1.5s ease-in-out infinite; + border-radius: 4px; + margin-bottom: 0.5rem; } -.pn-layout-compact .pn-article-excerpt, -.pn-layout-compact .pn-author, -.pn-layout-compact .pn-tags { - display: none; +.pn-skeleton-meta { + width: 140px; + height: 14px; } -/* ── Responsive: stack on small screens ────────────────────────── */ +.pn-skeleton-title { + width: 85%; + height: 20px; +} + +.pn-skeleton-excerpt { + width: 100%; + height: 14px; +} + +.pn-skeleton-excerpt-short { + width: 60%; + height: 14px; +} + +.pn-skeleton-tags { + display: flex; + gap: 0.375rem; + margin-top: 0.5rem; +} + +.pn-skeleton-tag { + width: 64px; + height: 22px; + border-radius: 100px; + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); + background-size: 200% 100%; + animation: pn-shimmer 1.5s ease-in-out infinite; +} + +@keyframes pn-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ── Error state ───────────────────────────────────────────────── */ +.pn-error { + text-align: center; + padding: 2.5rem 1rem; + color: #6b7280; +} + +.pn-error p { + font-size: 0.9375rem; + margin: 0 0 1rem; +} + +.pn-retry-btn { + display: inline-block; + padding: 0.5rem 1.25rem; + font-size: 0.875rem; + font-weight: 500; + color: #2563eb; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +.pn-retry-btn:hover { + background: #dbeafe; + border-color: #93c5fd; +} + +.pn-retry-btn:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +/* ── Empty state ───────────────────────────────────────────────── */ +.pn-empty { + text-align: center; + padding: 2.5rem 1rem; +} + +.pn-empty p { + font-size: 0.9375rem; + color: #6b7280; + margin: 0; +} + +/* ── Pagination ────────────────────────────────────────────────── */ +.pn-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 1.5rem 0 0.5rem; + margin-top: 0.5rem; + border-top: 1px solid #e8eaed; +} + +.pn-page-btn { + display: inline-flex; + align-items: center; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + color: #374151; + background: #fff; + border: 1px solid #d1d5db; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + line-height: 1; +} + +.pn-page-btn:hover:not(:disabled) { + background: #f9fafb; + border-color: #9ca3af; +} + +.pn-page-btn:disabled { + color: #d1d5db; + border-color: #e5e7eb; + cursor: not-allowed; +} + +.pn-page-btn:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.pn-page-info { + font-size: 0.8125rem; + color: #6b7280; + min-width: 100px; + text-align: center; +} + +/* ── Responsive ────────────────────────────────────────────────── */ @media (max-width: 600px) { - .pn-article { - flex-direction: column; - gap: 0.75rem; + .pn-article-title { + font-size: 1rem; } - .pn-article-thumb { - flex: none; - width: 100%; + .pn-article-excerpt { + font-size: 0.875rem; + -webkit-line-clamp: 2; } - .pn-article-thumb img { - height: 180px; + .pn-pagination { + gap: 0.5rem; + } + + .pn-page-btn { + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + } +} + +/* ── Dark mode support via prefers-color-scheme ────────────────── */ +@media (prefers-color-scheme: dark) { + .pn-news-feed { + color: #e5e7eb; + } + + .pn-article { + border-bottom-color: #374151; + } + + .pn-article-title { + color: #f3f4f6; + } + + .pn-article-title a:hover { + color: #60a5fa; + } + + .pn-source { + color: #9ca3af; + } + + .pn-article-excerpt { + color: #9ca3af; + } + + .pn-tag { + background: #1e3a5f; + color: #93c5fd; + } + + .pn-skeleton-line, + .pn-skeleton-tag { + background: linear-gradient(90deg, #1f2937 25%, #374151 50%, #1f2937 75%); + background-size: 200% 100%; + } + + .pn-page-btn { + color: #e5e7eb; + background: #1f2937; + border-color: #4b5563; + } + + .pn-page-btn:hover:not(:disabled) { + background: #374151; + } + + .pn-page-btn:disabled { + color: #4b5563; + border-color: #374151; + } + + .pn-retry-btn { + color: #60a5fa; + background: #1e3a5f; + border-color: #1d4ed8; + } + + .pn-pagination { + border-top-color: #374151; + } + + .pn-skeleton-card { + border-bottom-color: #374151; } } diff --git a/public/js/peptide-news-feed.js b/public/js/peptide-news-feed.js new file mode 100644 index 0000000..c3c2ee3 --- /dev/null +++ b/public/js/peptide-news-feed.js @@ -0,0 +1,367 @@ +/** + * Peptide News Feed — React Frontend + * + * A clean, accessible news feed component that displays peptide research + * articles with title, summary, source, date, and keyword tags. + * + * @since 2.0.0 + */ +const { + useState, + useEffect, + useCallback, + useRef, + memo +} = React; + +/* ── Constants ──────────────────────────────────────────────────────── */ + +const ARTICLES_PER_PAGE = 10; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/* ── Utilities ──────────────────────────────────────────────────────── */ + +/** + * Format an ISO date string to a human-readable form. + * + * @param {string} dateStr ISO date string. + * @returns {string} Formatted date (e.g., "Apr 5, 2026"). + */ +function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); +} + +/** + * Calculate a human-readable relative time string. + * + * @param {string} dateStr ISO date string. + * @returns {string} Relative time (e.g., "2 hours ago"). + */ +function timeAgo(dateStr) { + if (!dateStr) return ''; + const now = Date.now(); + const then = new Date(dateStr).getTime(); + if (isNaN(then)) return ''; + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return 'Just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 7) return `${diffDay}d ago`; + return formatDate(dateStr); +} + +/* ── Simple in-memory cache ─────────────────────────────────────────── */ + +const apiCache = new Map(); +function getCached(key) { + const entry = apiCache.get(key); + if (!entry) return null; + if (Date.now() - entry.ts > CACHE_TTL_MS) { + apiCache.delete(key); + return null; + } + return entry.data; +} +function setCache(key, data) { + apiCache.set(key, { + data, + ts: Date.now() + }); +} + +/* ── API Layer ──────────────────────────────────────────────────────── */ + +/** + * Fetch articles from the WP REST API with caching. + * + * @param {string} restUrl Base REST URL. + * @param {number} page Page number. + * @param {number} count Articles per page. + * @param {AbortSignal} signal Abort signal for cleanup. + * @returns {Promise} Response data. + */ +async function fetchArticles(restUrl, page, count, signal) { + const cacheKey = `${restUrl}:${page}:${count}`; + const cached = getCached(cacheKey); + if (cached) return cached; + const url = `${restUrl}articles?count=${count}&page=${page}`; + const resp = await fetch(url, { + signal + }); + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + const data = await resp.json(); + setCache(cacheKey, data); + return data; +} + +/* ── Click Tracking ─────────────────────────────────────────────────── */ + +/** + * Track an outbound click via sendBeacon or fallback XHR. + * + * @param {number} articleId + */ +function trackClick(articleId) { + const config = window.peptideNewsFeed || {}; + if (!config.ajaxUrl || !config.nonce) return; + const formData = new FormData(); + formData.append('action', 'peptide_news_track_click'); + formData.append('nonce', config.nonce); + formData.append('article_id', articleId); + formData.append('referrer', document.referrer || ''); + formData.append('page_url', window.location.href); + formData.append('session_id', config.sessionId || ''); + if (navigator.sendBeacon) { + navigator.sendBeacon(config.ajaxUrl, formData); + } else { + fetch(config.ajaxUrl, { + method: 'POST', + body: formData + }).catch(() => {}); + } +} + +/* ── Components ─────────────────────────────────────────────────────── */ + +/** + * Single keyword tag pill. + */ +const Tag = memo(function Tag({ + label +}) { + return /*#__PURE__*/React.createElement("span", { + className: "pn-tag" + }, label); +}); + +/** + * Single article card. + */ +const ArticleCard = memo(function ArticleCard({ + article +}) { + const summary = article.ai_summary || article.excerpt || ''; + const tagSource = article.tags || article.categories || ''; + const tags = tagSource ? tagSource.split(',').map(t => t.trim()).filter(Boolean).slice(0, 5) : []; + const handleClick = useCallback(() => { + trackClick(article.id); + }, [article.id]); + return /*#__PURE__*/React.createElement("article", { + className: "pn-article", + role: "article" + }, /*#__PURE__*/React.createElement("div", { + className: "pn-article-meta" + }, /*#__PURE__*/React.createElement("span", { + className: "pn-source" + }, article.source), /*#__PURE__*/React.createElement("span", { + className: "pn-separator", + "aria-hidden": "true" + }, "\xB7"), /*#__PURE__*/React.createElement("time", { + className: "pn-date", + dateTime: article.published_at, + title: formatDate(article.published_at) + }, timeAgo(article.published_at))), /*#__PURE__*/React.createElement("h3", { + className: "pn-article-title" + }, /*#__PURE__*/React.createElement("a", { + href: article.source_url, + onClick: handleClick, + target: "_blank", + rel: "noopener noreferrer" + }, article.title)), summary && /*#__PURE__*/React.createElement("p", { + className: "pn-article-excerpt" + }, summary), article.author && /*#__PURE__*/React.createElement("span", { + className: "pn-author" + }, article.author), tags.length > 0 && /*#__PURE__*/React.createElement("div", { + className: "pn-tags", + role: "list", + "aria-label": "Keywords" + }, tags.map(tag => /*#__PURE__*/React.createElement(Tag, { + key: tag, + label: tag + })))); +}); + +/** + * Loading skeleton placeholder. + */ +function LoadingSkeleton({ + count = 3 +}) { + return /*#__PURE__*/React.createElement("div", { + className: "pn-loading", + "aria-busy": "true", + "aria-label": "Loading articles" + }, Array.from({ + length: count + }, (_, i) => /*#__PURE__*/React.createElement("div", { + key: i, + className: "pn-skeleton-card" + }, /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-line pn-skeleton-meta" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-line pn-skeleton-title" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-line pn-skeleton-excerpt" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-line pn-skeleton-excerpt-short" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-tags" + }, /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-tag" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-tag" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-tag" + }))))); +} + +/** + * Error state with retry. + */ +function ErrorMessage({ + message, + onRetry +}) { + return /*#__PURE__*/React.createElement("div", { + className: "pn-error", + role: "alert" + }, /*#__PURE__*/React.createElement("p", null, "Unable to load articles. ", message), /*#__PURE__*/React.createElement("button", { + className: "pn-retry-btn", + onClick: onRetry, + type: "button" + }, "Try Again")); +} + +/** + * Empty state. + */ +function EmptyState() { + return /*#__PURE__*/React.createElement("div", { + className: "pn-empty" + }, /*#__PURE__*/React.createElement("p", null, "No peptide news articles available yet. Check back soon!")); +} + +/** + * Pagination controls. + */ +const Pagination = memo(function Pagination({ + page, + totalPages, + onPageChange +}) { + if (totalPages <= 1) return null; + return /*#__PURE__*/React.createElement("nav", { + className: "pn-pagination", + "aria-label": "Article pages" + }, /*#__PURE__*/React.createElement("button", { + className: "pn-page-btn", + onClick: () => onPageChange(page - 1), + disabled: page <= 1, + "aria-label": "Previous page", + type: "button" + }, "\u2190 Newer"), /*#__PURE__*/React.createElement("span", { + className: "pn-page-info", + "aria-current": "page" + }, "Page ", page, " of ", totalPages), /*#__PURE__*/React.createElement("button", { + className: "pn-page-btn", + onClick: () => onPageChange(page + 1), + disabled: page >= totalPages, + "aria-label": "Next page", + type: "button" + }, "Older \u2192")); +}); + +/* ── Main Feed Component ────────────────────────────────────────────── */ + +/** + * PeptideNewsFeed — root component. + * + * Reads configuration from window.peptideNewsFeed which is + * localized by the PHP shortcode renderer. + */ +function PeptideNewsFeed() { + const config = window.peptideNewsFeed || {}; + const restUrl = config.restUrl || '/wp-json/peptide-news/v1/'; + const perPage = config.count || ARTICLES_PER_PAGE; + const [articles, setArticles] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const feedRef = useRef(null); + const loadArticles = useCallback(async (pageNum, signal) => { + setLoading(true); + setError(null); + try { + const data = await fetchArticles(restUrl, pageNum, perPage, signal); + setArticles(data.articles || []); + setTotalPages(data.total_pages || 1); + } catch (err) { + if (err.name !== 'AbortError') { + setError(err.message || 'Something went wrong.'); + } + } finally { + setLoading(false); + } + }, [restUrl, perPage]); + useEffect(() => { + const controller = new AbortController(); + loadArticles(page, controller.signal); + return () => controller.abort(); + }, [page, loadArticles]); + const handlePageChange = useCallback(newPage => { + setPage(newPage); + // Scroll feed container into view on pagination. + if (feedRef.current) { + feedRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }, []); + const handleRetry = useCallback(() => { + loadArticles(page); + }, [page, loadArticles]); + return /*#__PURE__*/React.createElement("div", { + className: "pn-news-feed", + ref: feedRef + }, loading && /*#__PURE__*/React.createElement(LoadingSkeleton, { + count: Math.min(perPage, 5) + }), !loading && error && /*#__PURE__*/React.createElement(ErrorMessage, { + message: error, + onRetry: handleRetry + }), !loading && !error && articles.length === 0 && /*#__PURE__*/React.createElement(EmptyState, null), !loading && !error && articles.length > 0 && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + className: "pn-articles-list", + role: "feed", + "aria-label": "Peptide news articles" + }, articles.map(article => /*#__PURE__*/React.createElement(ArticleCard, { + key: article.id, + article: article + }))), /*#__PURE__*/React.createElement(Pagination, { + page: page, + totalPages: totalPages, + onPageChange: handlePageChange + }))); +} + +/* ── Mount ──────────────────────────────────────────────────────────── */ + +document.addEventListener('DOMContentLoaded', function () { + const containers = document.querySelectorAll('[data-peptide-news-feed]'); + containers.forEach(function (el) { + const root = ReactDOM.createRoot(el); + root.render(React.createElement(PeptideNewsFeed)); + }); +}); diff --git a/public/js/public-script.js b/public/js/public-script.js index ff350da..ef97f53 100644 --- a/public/js/public-script.js +++ b/public/js/public-script.js @@ -1,83 +1,9 @@ /** - * Peptide News — Public Click Tracker + * Peptide News — Legacy Click Tracker (deprecated) * - * Tracks outbound clicks on article links via AJAX and - * manages visitor session cookies. + * Click tracking is now handled by the React feed component. + * This file is kept as an empty placeholder for backward compatibility. * * @since 1.0.0 + * @deprecated 2.0.0 Use peptide-news-feed.js instead. */ -(function ($) { - 'use strict'; - - // Set session cookie if it doesn't exist. - function initSession() { - if (!getCookie('pn_session_id')) { - var sessionId = peptideNewsPublic.session_id || generateUUID(); - setCookie('pn_session_id', sessionId, 30); // 30-minute cookie - } - } - - function getCookie(name) { - var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); - return match ? decodeURIComponent(match[2]) : null; - } - - function setCookie(name, value, minutes) { - var d = new Date(); - d.setTime(d.getTime() + minutes * 60 * 1000); - var cookie = name + '=' + encodeURIComponent(value) + - ';expires=' + d.toUTCString() + - ';path=/;SameSite=Lax'; - if (window.location.protocol === 'https:') { - cookie += ';Secure'; - } - document.cookie = cookie; - } - - function generateUUID() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - var r = (Math.random() * 16) | 0; - var v = c === 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); - } - - // Track click via AJAX (fire-and-forget with beacon fallback). - function trackClick(articleId) { - var data = { - action: 'peptide_news_track_click', - nonce: peptideNewsPublic.nonce, - article_id: articleId, - referrer: document.referrer || '', - page_url: window.location.href, - session_id: getCookie('pn_session_id') || '', - }; - - // Try sendBeacon first (non-blocking, survives page navigation). - if (navigator.sendBeacon) { - var formData = new FormData(); - Object.keys(data).forEach(function (key) { - formData.append(key, data[key]); - }); - navigator.sendBeacon(peptideNewsPublic.ajax_url, formData); - return; - } - - // Fallback to AJAX. - $.post(peptideNewsPublic.ajax_url, data); - } - - $(document).ready(function () { - initSession(); - - // Delegate click events on tracked links. - $(document).on('click', '.pn-track-click', function () { - var articleId = $(this).data('article-id'); - if (articleId) { - trackClick(articleId); - } - // Don't prevent default — let the link open normally. - }); - }); - -})(jQuery); diff --git a/public/react-app/PeptideNewsFeed.jsx b/public/react-app/PeptideNewsFeed.jsx new file mode 100644 index 0000000..7d38654 --- /dev/null +++ b/public/react-app/PeptideNewsFeed.jsx @@ -0,0 +1,374 @@ +/** + * Peptide News Feed — React Frontend + * + * A clean, accessible news feed component that displays peptide research + * articles with title, summary, source, date, and keyword tags. + * + * @since 2.0.0 + */ +const { useState, useEffect, useCallback, useRef, memo } = React; + +/* ── Constants ──────────────────────────────────────────────────────── */ + +const ARTICLES_PER_PAGE = 10; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/* ── Utilities ──────────────────────────────────────────────────────── */ + +/** + * Format an ISO date string to a human-readable form. + * + * @param {string} dateStr ISO date string. + * @returns {string} Formatted date (e.g., "Apr 5, 2026"). + */ +function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +/** + * Calculate a human-readable relative time string. + * + * @param {string} dateStr ISO date string. + * @returns {string} Relative time (e.g., "2 hours ago"). + */ +function timeAgo(dateStr) { + if (!dateStr) return ''; + const now = Date.now(); + const then = new Date(dateStr).getTime(); + if (isNaN(then)) return ''; + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return 'Just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 7) return `${diffDay}d ago`; + return formatDate(dateStr); +} + +/* ── Simple in-memory cache ─────────────────────────────────────────── */ + +const apiCache = new Map(); + +function getCached(key) { + const entry = apiCache.get(key); + if (!entry) return null; + if (Date.now() - entry.ts > CACHE_TTL_MS) { + apiCache.delete(key); + return null; + } + return entry.data; +} + +function setCache(key, data) { + apiCache.set(key, { data, ts: Date.now() }); +} + +/* ── API Layer ──────────────────────────────────────────────────────── */ + +/** + * Fetch articles from the WP REST API with caching. + * + * @param {string} restUrl Base REST URL. + * @param {number} page Page number. + * @param {number} count Articles per page. + * @param {AbortSignal} signal Abort signal for cleanup. + * @returns {Promise} Response data. + */ +async function fetchArticles(restUrl, page, count, signal) { + const cacheKey = `${restUrl}:${page}:${count}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const url = `${restUrl}articles?count=${count}&page=${page}`; + const resp = await fetch(url, { signal }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + const data = await resp.json(); + setCache(cacheKey, data); + return data; +} + +/* ── Click Tracking ─────────────────────────────────────────────────── */ + +/** + * Track an outbound click via sendBeacon or fallback XHR. + * + * @param {number} articleId + */ +function trackClick(articleId) { + const config = window.peptideNewsFeed || {}; + if (!config.ajaxUrl || !config.nonce) return; + + const formData = new FormData(); + formData.append('action', 'peptide_news_track_click'); + formData.append('nonce', config.nonce); + formData.append('article_id', articleId); + formData.append('referrer', document.referrer || ''); + formData.append('page_url', window.location.href); + formData.append('session_id', config.sessionId || ''); + + if (navigator.sendBeacon) { + navigator.sendBeacon(config.ajaxUrl, formData); + } else { + fetch(config.ajaxUrl, { method: 'POST', body: formData }).catch(() => {}); + } +} + +/* ── Components ─────────────────────────────────────────────────────── */ + +/** + * Single keyword tag pill. + */ +const Tag = memo(function Tag({ label }) { + return {label}; +}); + +/** + * Single article card. + */ +const ArticleCard = memo(function ArticleCard({ article }) { + const summary = article.ai_summary || article.excerpt || ''; + const tagSource = article.tags || article.categories || ''; + const tags = tagSource + ? tagSource.split(',').map((t) => t.trim()).filter(Boolean).slice(0, 5) + : []; + + const handleClick = useCallback(() => { + trackClick(article.id); + }, [article.id]); + + return ( +
+
+ {article.source} + + +
+ +

+ + {article.title} + +

+ + {summary && ( +

{summary}

+ )} + + {article.author && ( + {article.author} + )} + + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + ))} +
+ )} +
+ ); +}); + +/** + * Loading skeleton placeholder. + */ +function LoadingSkeleton({ count = 3 }) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +/** + * Error state with retry. + */ +function ErrorMessage({ message, onRetry }) { + return ( +
+

Unable to load articles. {message}

+ +
+ ); +} + +/** + * Empty state. + */ +function EmptyState() { + return ( +
+

No peptide news articles available yet. Check back soon!

+
+ ); +} + +/** + * Pagination controls. + */ +const Pagination = memo(function Pagination({ page, totalPages, onPageChange }) { + if (totalPages <= 1) return null; + + return ( + + ); +}); + +/* ── Main Feed Component ────────────────────────────────────────────── */ + +/** + * PeptideNewsFeed — root component. + * + * Reads configuration from window.peptideNewsFeed which is + * localized by the PHP shortcode renderer. + */ +function PeptideNewsFeed() { + const config = window.peptideNewsFeed || {}; + const restUrl = config.restUrl || '/wp-json/peptide-news/v1/'; + const perPage = config.count || ARTICLES_PER_PAGE; + + const [articles, setArticles] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const feedRef = useRef(null); + + const loadArticles = useCallback( + async (pageNum, signal) => { + setLoading(true); + setError(null); + try { + const data = await fetchArticles(restUrl, pageNum, perPage, signal); + setArticles(data.articles || []); + setTotalPages(data.total_pages || 1); + } catch (err) { + if (err.name !== 'AbortError') { + setError(err.message || 'Something went wrong.'); + } + } finally { + setLoading(false); + } + }, + [restUrl, perPage] + ); + + useEffect(() => { + const controller = new AbortController(); + loadArticles(page, controller.signal); + return () => controller.abort(); + }, [page, loadArticles]); + + const handlePageChange = useCallback((newPage) => { + setPage(newPage); + // Scroll feed container into view on pagination. + if (feedRef.current) { + feedRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, []); + + const handleRetry = useCallback(() => { + loadArticles(page); + }, [page, loadArticles]); + + return ( +
+ {loading && } + + {!loading && error && ( + + )} + + {!loading && !error && articles.length === 0 && } + + {!loading && !error && articles.length > 0 && ( + <> +
+ {articles.map((article) => ( + + ))} +
+ + + + )} +
+ ); +} + +/* ── Mount ──────────────────────────────────────────────────────────── */ + +document.addEventListener('DOMContentLoaded', function () { + const containers = document.querySelectorAll('[data-peptide-news-feed]'); + containers.forEach(function (el) { + const root = ReactDOM.createRoot(el); + root.render(React.createElement(PeptideNewsFeed)); + }); +});