js • Lines: 190import { Link } from "react-router-dom";
import { getEnrichedSwe, getEnrichedMusic } from "../utils/mockData";
import { getContentsLoadError } from "../config/findProp";
/**
* Maps raw fetch errors to user-friendly messages.
*/
function friendlyFetchError(msg) {
if (!msg) return "Content data failed to load.";
const m = msg.toLowerCase();
if (m.includes("cors")) return "The site is not allowed to fetch content.";
if (m.includes("failed to fetch") || m.includes("networkerror")) return "Content blocked by browser.";
if (m.includes("http 404") || m.includes("not found")) return "Content not found (404).";
if (m.includes("http 403") || m.includes("forbidden")) return "Access forbidden (403).";
if (m.includes("http 500") || m.includes("internal server")) return "Server error (500).";
return `Content data failed to load: ${msg}`;
}
/**
* Safely extracts a timestamp integer from a content item.
*/
function getDateVal(c) {
return c.sortBy
? new Date(c.sortBy).getTime()
: c.publishDate
? new Date(c.publishDate).getTime()
: 0;
}
const partLinkStyle = {
display: "block",
color: "#8892b0",
textDecoration: "none",
padding: "5px 8px 5px 24px",
fontSize: "14px",
lineHeight: 1.5,
borderRadius: "4px",
transition: "background 0.15s",
};
export default function SeriesList({ type, filter, showDate, limit, truncateAt, flat }) {
// Fetch initial data array based on type
const allItems = type === "contents.swe" ? getEnrichedSwe() : getEnrichedMusic();
// Filter items matching the search query if one exists
const filtered = filter
? allItems.filter((c) => (c.title || "").toLowerCase().includes(filter.toLowerCase()))
: allItems;
const seriesMap = {};
const standalone = [];
// Group items into either a specific series or standalone collections
for (const c of filtered) {
if (c.series) {
if (!seriesMap[c.series]) seriesMap[c.series] = [];
seriesMap[c.series].push(c);
} else {
standalone.push(c);
}
}
// Map and sort series structures by latest updated item
const sortedSeries = Object.entries(seriesMap)
.map(([name, articles]) => ({
name,
count: articles.length,
articles: articles.sort((a, b) => a.seriesOrder - b.seriesOrder),
latestDate: Math.max(...articles.map(getDateVal)),
}))
.sort((a, b) => b.latestDate - a.latestDate);
// FIXED: Removed the .filter(c => getDateVal(c) > 0) constraint
// to avoid dropping standalone entries lacking publish dates.
const sortedStandalone = standalone
.sort((a, b) => getDateVal(b) - getDateVal(a));
let seriesShown = 0;
let standaloneShown = 0;
const rows = [];
if (flat) {
// Flat Mode: Display layout linearly
for (const s of sortedSeries) {
if (limit && seriesShown + standaloneShown >= limit) break;
seriesShown++;
rows.push({ kind: "series-link", data: s });
}
for (const a of sortedStandalone) {
if (limit && seriesShown + standaloneShown >= limit) break;
standaloneShown++;
rows.push({ kind: "article", data: a });
}
} else {
// Nested Mode: Group articles underneath their respective series headers
for (const s of sortedSeries) {
if (limit && seriesShown + standaloneShown >= limit) break;
seriesShown++;
rows.push({ kind: "series", data: s });
for (const a of s.articles) {
rows.push({ kind: "part", data: a, series: s.name });
}
}
for (const a of sortedStandalone) {
if (limit && seriesShown + standaloneShown >= limit) break;
standaloneShown++;
rows.push({ kind: "article", data: a });
}
}
// Handle fallback UI states
if (rows.length === 0) {
const loadErr = getContentsLoadError();
// If data is populated but rows are empty, it's an explicit filtering limit statement
if (allItems.length > 0) {
return <p className="text-secondary small">No content matches your active filters.</p>;
}
const msg = loadErr ? friendlyFetchError(loadErr) : "No content found.";
return <p className="text-secondary small">{msg}</p>;
}
return (
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{rows.map((item, idx) => {
if (item.kind === "series-link") {
const s = item.data;
return (
<li key={s.name} style={{ padding: "10px 8px", borderBottom: "1px solid rgba(255,255,255,0.04)" }}>
<Link to={`/content/${s.articles[0].id}`} style={{ textDecoration: "none", display: "block" }}>
<div style={{ color: "#38bdf8", fontWeight: 700, fontSize: "14px" }}>{s.name}</div>
<div style={{ color: "#6c757d", fontSize: "11px", marginTop: "2px" }}>
{s.count} {s.count === 1 ? "part" : "parts"}
</div>
</Link>
</li>
);
}
if (item.kind === "series") {
const s = item.data;
return (
<li key={`series-${s.name}`} style={{ padding: "10px 8px 4px 8px", marginTop: idx > 0 ? "12px" : 0 }}>
<div style={{ color: "#38bdf8", fontWeight: 700, fontSize: "15px", textTransform: "uppercase", letterSpacing: "0.5px" }}>
{s.name}
</div>
<div style={{ color: "#6c757d", fontSize: "11px", marginBottom: "4px" }}>
{s.count} {s.count === 1 ? "part" : "parts"}
</div>
</li>
);
}
if (item.kind === "part") {
const a = item.data;
const title = truncateAt && a.title.length > truncateAt
? a.title.slice(0, a.title.lastIndexOf(" ", truncateAt)) + "…"
: a.title;
return (
<li key={a.id} style={{ listStyle: "none" }}>
<Link
to={`/content/${a.id}`}
style={partLinkStyle}
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255,255,255,0.03)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
<span style={{ color: "#6c757d", marginRight: "6px", fontSize: "13px" }}>#{a.seriesOrder}</span>
{title}
</Link>
</li>
);
}
const a = item.data;
const title = truncateAt && a.title.length > truncateAt
? a.title.slice(0, a.title.lastIndexOf(" ", truncateAt)) + "…"
: a.title;
return (
<li key={a.id} style={{ listStyle: "none", padding: "8px 8px", borderBottom: "1px solid rgba(255,255,255,0.04)" }}>
{showDate && a.publishDate ? (
<span style={{ color: "#6c757d", fontSize: "12px", marginRight: "8px" }}>{a.publishDate}</span>
) : null}
<Link to={`/content/${a.id}`} style={{ color: "#e2e8f0", textDecoration: "none", fontSize: "14px" }}>
{title}
</Link>
</li>
);
})}
</ul>
);
}