diff --git a/peptide-news-plugin/includes/class-peptide-news-rest-api.php b/peptide-news-plugin/includes/class-peptide-news-rest-api.php index a7913c5..9f25f0c 100644 --- a/peptide-news-plugin/includes/class-peptide-news-rest-api.php +++ b/peptide-news-plugin/includes/class-peptide-news-rest-api.php @@ -1,22 +1,273 @@ 'GET', - 'callback' => array( $this, 'get_articles' ), - 'permission_callback' => array( $this, 'rublic_permission' ), - ) + + // Public: list articles. + register_rest_route( self::API_NAMESPACE, '/articles', array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_articles' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'count' => array( + 'default' => 10, + 'sanitize_callback' => 'absint', + ), + 'page' => array( + 'default' => 1, + 'sanitize_callback' => 'absint', + ), + ), + ) ); + + // Admin-only analytics endpoints. + $analytics_routes = array( + '/analytics/top' => 'get_top_articles', + '/analytics/trends' => 'get_trends', + '/analytics/topics' => 'get_topics', + '/analytics/devices' => 'get_devices', + '/analytics/sources' => 'get_sources', + '/analytics/export' => 'get_export', + ); + + foreach ( $analytics_routes as $route => $callback ) { + register_rest_route( self::API_NAMESPACE, $route, array( + 'methods' => 'GET', + 'callback' => array( $this, $callback ), + 'permission_callback' => array( $this, 'check_admin_permissions' ), + 'args' => $this->get_date_range_args(), + ) ); + } + } + + /** + * Verify the request comes from an admin user. + * + * @param WP_REST_Request $request + * @return bool|WP_Error + */ + public function check_admin_permissions( $request ) { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to access analytics data.', 'peptide-news' ), + array( 'status' => 403 ) + ); + } + return true; + } + + /** + * Common date-range argument definitions. + * + * @return array + */ + private function get_date_range_args() { + return array( + 'start_date' => array( + 'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ), + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => array( $this, 'validate_date_format' ), + ), + 'end_date' => array( + 'default' => gmdate( 'Y-m-d' ), + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => array( $this, 'validate_date_format' ), + ), + 'limit' => array( + 'default' => 20, + 'sanitize_callback' => 'absint', + ), ); } + + /** + * Validate that a parameter is a Y-m-d date string. + * + * @param string $value + * @param WP_REST_Request $request + * @param string $param + * @return true|WP_Error + */ + public function validate_date_format( $value, $request, $param ) { + if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) { + return new WP_Error( + 'rest_invalid_date', + sprintf( __( 'Invalid date format for %s. Expected Y-m-d.', 'peptide-news' ), $param ), + array( 'status' => 400 ) + ); + } + return true; + } + + /** + * GET /articles + */ + public function get_articles( $request ) { + global $wpdb; + + $count = min( $request->get_param( 'count' ), 100 ); + $page = max( $request->get_param( 'page' ), 1 ); + $offset = ( $page - 1 ) * $count; + $table = $wpdb->prefix . 'peptide_news_articles'; + + $articles = $wpdb->get_results( $wpdb->prepare( + "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 + LIMIT %d OFFSET %d", + $count, + $offset + ) ); + + // Strip source name from titles and summaries (e.g. "Article Title - Toronto Star"). + foreach ( $articles as $article ) { + $article->title = $this->strip_source_suffix( $article->title, $article->source ); + if ( ! empty( $article->ai_summary ) ) { + $article->ai_summary = $this->strip_source_suffix( $article->ai_summary, $article->source ); + } + if ( ! empty( $article->excerpt ) ) { + $article->excerpt = $this->strip_source_suffix( $article->excerpt, $article->source ); + } + } + + $total = $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE is_active = 1" ); + + return rest_ensure_response( array( + 'articles' => $articles, + 'total' => (int) $total, + 'page' => $page, + 'per_page' => $count, + 'total_pages' => ceil( $total / $count ), + ) ); + } + + /** + * Strip the source name from the end of a string. + * + * Handles common separator patterns like " - Source", " | Source", + * " — Source", and " – Source". + * + * @param string $text The text to clean. + * @param string $source The source name to strip. + * @return string Cleaned text. + */ + private function strip_source_suffix( $text, $source ) { + if ( empty( $source ) || empty( $text ) ) { + return $text; + } + + // Match common separators: " - ", " | ", " — ", " – " + $separators = array( ' - ', ' | ', ' — ', ' – ', ' // ' ); + + foreach ( $separators as $sep ) { + $suffix = $sep . $source; + if ( substr( $text, -strlen( $suffix ) ) === $suffix ) { + return rtrim( substr( $text, 0, -strlen( $suffix ) ) ); + } + } + + return $text; + } + + /** + * GET /analytics/top + */ + public function get_top_articles( $request ) { + $data = Peptide_News_Analytics::get_top_articles( + $request->get_param( 'start_date' ), + $request->get_param( 'end_date' ), + $request->get_param( 'limit' ) + ); + return rest_ensure_response( $data ); + } + + /** + * GET /analytics/trends + */ + public function get_trends( $request ) { + $data = Peptide_News_Analytics::get_click_trends( + $request->get_param( 'start_date' ), + $request->get_param( 'end_date' ) + ); + return rest_ensure_response( $data ); + } + + /** + * GET /analytics/topics + */ + public function get_topics( $request ) { + $data = Peptide_News_Analytics::get_popular_topics( + $request->get_param( 'start_date' ), + $request->get_param( 'end_date' ), + $request->get_param( 'limit' ) + ); + return rest_ensure_response( $data ); + } + + /** + * GET /analytics/devices + */ + public function get_devices( $request ) { + $data = Peptide_News_Analytics::get_device_breakdown( + $request->get_param( 'start_date' ), + $request->get_param( 'end_date' ) + ); + return rest_ensure_response( $data ); + } + + /** + * GET /analytics/sources + */ + public function get_sources( $request ) { + $data = Peptide_News_Analytics::get_source_performance( + $request->get_param( 'start_date' ), + $request->get_param( 'end_date' ) + ); + return rest_ensure_response( $data ); + } + + /** + * GET /analytics/export — returns CSV download. + */ + public function get_export( $request ) { + $data = Peptide_News_Analytics::export_clicks_csv( + $request->get_param( 'start_date' ), + $request->get_param( 'end_date' ) + ); + + if ( empty( $data ) ) { + return rest_ensure_response( array( 'message' => 'No data for the selected period.' ) ); + } + + // Return as JSON (client can convert to CSV). + return rest_ensure_response( array( + 'headers' => array_keys( $data[0] ), + 'rows' => $data, + 'count' => count( $data ), + ) ); + } } diff --git a/peptide-news-plugin/public/css/public-style.css b/peptide-news-plugin/public/css/public-style.css index e4c4079..a687282 100644 --- a/peptide-news-plugin/public/css/public-style.css +++ b/peptide-news-plugin/public/css/public-style.css @@ -35,10 +35,6 @@ --pn-source-color: #1f2937; --pn-source-bg: #f3f4f6; - /* Tag Colors */ - --pn-tag-bg: #eff6ff; - --pn-tag-color: #1e40af; - /* Focus & System */ --pn-focus-ring: #2563eb; --pn-error: #dc2626; @@ -71,10 +67,6 @@ --pn-source-color: #e2e8f0; --pn-source-bg: #1e293b; - /* Tag Colors */ - --pn-tag-bg: #1e3a8a; - --pn-tag-color: #bfdbfe; - /* Focus & System */ --pn-focus-ring: #60a5fa; --pn-error: #f87171; @@ -258,42 +250,6 @@ content: ''; } -/* ──────────────────────────────────────────────────────────────── - TAGS - ────────────────────────────────────────────────────────────── */ - -.pn-tags { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin: auto 0 0 0; -} - -.pn-tag { - display: inline-block; - padding: 4px 10px; - font-size: 12px; - font-weight: 500; - line-height: 1.3; - letter-spacing: 0.15px; - color: var(--pn-tag-color); - background: var(--pn-tag-bg); - border-radius: 20px; - cursor: pointer; - transition: all 0.15s ease; -} - -.pn-tag:hover { - background: color-mix(in srgb, var(--pn-tag-bg) 70%, var(--pn-tag-color) 30%); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(37, 99, 235, 0.15); -} - -.pn-tag:focus-visible { - outline: 2px solid var(--pn-focus-ring); - outline-offset: 2px; -} - /* ══════════════════════════════════════════════════════════════════ LOADING STATES & SKELETON ══════════════════════════════════════════════════════════════════ */ @@ -367,19 +323,6 @@ animation: pn-shimmer 2s infinite 0.4s; } -.pn-skeleton-tags { - display: flex; - gap: 8px; - margin-top: 16px; -} - -.pn-skeleton-tag { - height: 10px; - width: 60px; - border-radius: 20px; - animation: pn-shimmer 2s infinite 0.5s; -} - @keyframes pn-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } @@ -597,16 +540,6 @@ font-size: 12px; } - .pn-tag { - padding: 3px 8px; - font-size: 11.5px; - } - - .pn-tags { - gap: 6px; - margin-top: 10px; - } - .pn-skeleton-card { padding: 16px; margin-bottom: 16px; @@ -699,11 +632,6 @@ color: var(--pn-text-tertiary); } - .pn-tag { - background: var(--pn-tag-bg); - color: var(--pn-tag-color); - } - .pn-page-btn { background: transparent; border-color: var(--pn-border-light); @@ -748,8 +676,7 @@ color: var(--pn-text-tertiary); } - .pn-skeleton-line, - .pn-skeleton-tag { + .pn-skeleton-line { background: linear-gradient( 90deg, var(--pn-loading) 0%, @@ -781,8 +708,7 @@ .pn-article-title a:focus-visible, .pn-page-btn:focus-visible, - .pn-retry-btn:focus-visible, - .pn-tag:focus-visible { + .pn-retry-btn:focus-visible { outline-width: 3px; } } @@ -791,11 +717,9 @@ @media (prefers-reduced-motion: reduce) { .pn-article, .pn-article-title a, - .pn-tag, .pn-page-btn, .pn-retry-btn, - .pn-skeleton-line, - .pn-skeleton-tag { + .pn-skeleton-line { animation: none !important; transition: none !important; } diff --git a/peptide-news-plugin/public/js/peptide-news-feed.js b/peptide-news-plugin/public/js/peptide-news-feed.js new file mode 100644 index 0000000..01777d8 --- /dev/null +++ b/peptide-news-plugin/public/js/peptide-news-feed.js @@ -0,0 +1,339 @@ +/** + * 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 article card. + */ +const ArticleCard = memo(function ArticleCard({ + article +}) { + const summary = article.ai_summary || article.excerpt || ''; + 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)); +}); + +/** + * 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" + })))); +} + +/** + * 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)); + }); +});