fix: remove keyword tags from article cards and strip source names from titles
Keywords/tags were redundant UI clutter at the bottom of article cards. Source names appended to titles (e.g. "Article Title - Toronto Star") are now stripped server-side in the REST API since source is already displayed in the card metadata. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,273 @@
|
||||
<?php
|
||||
/**
|
||||
* REST API Class - Endpoints for analytics and articles.
|
||||
* REST API endpoints for external analytics access.
|
||||
*
|
||||
* Registers custom routes to access articles and analytics data via HTTP JSON.
|
||||
* Base route: /wp-json/peptide-news/v1/
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /articles — list stored articles
|
||||
* GET /analytics/top — top articles by clicks
|
||||
* GET /analytics/trends — daily click trend data
|
||||
* GET /analytics/topics — popular topics/categories
|
||||
* GET /analytics/devices — device breakdown
|
||||
* GET /analytics/sources — source performance
|
||||
* GET /analytics/export — CSV export of raw click data
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Peptide_News_Rest_API {
|
||||
|
||||
class Peptide_News_REST_API {
|
||||
const API_NAMESPACE = 'peptide-news/v1';
|
||||
|
||||
/**
|
||||
* Register REST routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
// Get stored articles
|
||||
register_rest_rout( '/peptide-news/v1/articles',
|
||||
array(
|
||||
'methods' => '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 ),
|
||||
) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
339
peptide-news-plugin/public/js/peptide-news-feed.js
Normal file
339
peptide-news-plugin/public/js/peptide-news-feed.js
Normal file
@@ -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<Object>} 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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user