Files

1211 lines
38 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tech News Hub</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
/* Default Theme */
--bg-color: hsl(50, 1%, 6%);
--bg-gradient: var(--bg-color);
--card-bg: hsl(50, 1%, 12%);
--card-gradient: var(--card-bg);
--card-hover-bg: hsl(50, 1%, 15%);
--card-hover-gradient: var(--card-hover-bg);
--text-color: hsl(0, 0%, 95%);
--text-muted: hsl(0, 0%, 70%);
--primary-color: hsl(24, 97%, 58%); /* Salmon/Orange */
--secondary-color: hsl(209, 88%, 54%); /* Blue */
--accent-green: hsl(145, 63%, 42%);
--border-color: rgba(255, 255, 255, 0.08);
--card-border-hover: rgba(244, 116, 50, 0.2);
--font-main: 'Outfit', sans-serif;
--font-heading: 'Outfit', sans-serif;
}
[data-theme="jonah"] {
/* Jonahlahona Premium Theme */
--bg-gradient: radial-gradient(circle at top left, #281111 0%, #0d0505 100%);
--card-gradient: linear-gradient(145deg, #1b0d0d 0%, #0c0505 100%);
--card-hover-gradient: linear-gradient(145deg, #251212 0%, #110707 100%);
--text-color: #cdbcbc;
--text-muted: rgba(205, 188, 188, 0.7);
--primary-color: #5d4646; /* Accent button */
--secondary-color: #cdbcbc;
--accent-green: hsl(145, 63%, 42%);
--border-color: #3a2221;
--card-border-hover: #cdbcbc;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-main);
background: var(--bg-gradient);
color: var(--text-color);
padding: 20px;
overflow-x: hidden;
min-height: 100vh;
margin: 0;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-color);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.header-title {
display: flex;
align-items: center;
gap: 10px;
}
.header-title h1 {
font-family: var(--font-heading);
font-size: 2rem;
font-weight: normal;
color: var(--text-color);
}
.refresh-btn {
background: var(--card-gradient);
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 8px 16px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
}
.refresh-btn:hover {
background: var(--card-hover-gradient);
border-color: var(--card-border-hover);
transform: translateY(-1px);
}
.refresh-btn:active {
transform: translateY(0);
}
/* Filters/Tabs */
.filter-tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.filter-tab {
background: var(--card-gradient);
border: 1px solid var(--border-color);
color: var(--text-muted);
padding: 6px 14px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.filter-tab:hover {
background: var(--card-hover-gradient);
color: var(--text-color);
}
.filter-tab.active {
background: var(--primary-color);
color: #fff;
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(244, 116, 50, 0.25);
}
/* Grid Layout */
.news-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
opacity: 1;
transition: opacity 0.3s ease;
}
.news-grid.loading {
opacity: 0.5;
pointer-events: none;
}
/* Card Design */
.news-card {
background: var(--card-gradient);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
height: 380px;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
cursor: pointer;
text-decoration: none;
color: inherit;
}
.news-card:hover {
transform: translateY(-4px);
border-color: var(--card-border-hover);
background: var(--card-hover-gradient);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(205, 188, 188, 0.05);
}
.card-image-container {
width: 100%;
height: 170px;
overflow: hidden;
position: relative;
background: #1a1a1a;
}
.card-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.news-card:hover .card-image {
transform: scale(1.05);
}
/* Fallback brand background for cards with no image */
.card-fallback-bg {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: var(--card-gradient);
position: relative;
}
.card-fallback-text {
font-size: 3rem;
font-weight: 700;
opacity: 0.15;
letter-spacing: -2px;
text-transform: uppercase;
user-select: none;
}
.card-content {
padding: 16px;
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: space-between;
overflow: hidden;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 8px;
}
.source-badge {
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border-color);
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
color: var(--text-color);
}
.pub-date {
color: var(--text-muted);
}
.card-title {
font-family: var(--font-heading);
font-size: 1.2rem;
font-weight: normal;
line-height: 1.4;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
color: var(--text-color);
transition: color 0.2s ease;
}
.news-card:hover .card-title {
color: var(--primary-color);
}
.card-desc {
font-size: 0.85rem;
line-height: 1.5;
color: var(--text-muted);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: auto;
}
/* Error Message Panel */
.error-panel {
grid-column: 1/-1;
background: rgba(209, 88, 54, 0.15);
border: 1px solid var(--primary-color);
padding: 16px;
border-radius: 8px;
color: var(--text-color);
font-size: 0.9rem;
margin-bottom: 20px;
}
/* Loader skeleton */
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.skeleton-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
height: 380px;
overflow: hidden;
}
.skeleton-img {
height: 170px;
background: linear-gradient(90deg, #1c1c1c 25%, #252525 50%, #1c1c1c 75%);
background-size: 200% 100%;
animation: loading-animation 1.5s infinite;
}
.skeleton-text {
padding: 16px;
}
.skeleton-line {
height: 12px;
background: linear-gradient(90deg, #1c1c1c 25%, #252525 50%, #1c1c1c 75%);
background-size: 200% 100%;
animation: loading-animation 1.5s infinite;
margin-bottom: 12px;
border-radius: 4px;
}
.skeleton-line.short { width: 40%; }
.skeleton-line.mid { width: 85%; }
.skeleton-line.long { width: 100%; }
@keyframes loading-animation {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Karakeep Integration Styles */
.card-image-container {
position: relative;
}
.save-to-karakeep-btn {
position: absolute;
top: 10px;
right: 10px;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
backdrop-filter: blur(4px);
transition: all 0.2s ease;
z-index: 10;
}
.save-to-karakeep-btn:hover {
background: var(--accent-color);
border-color: var(--accent-color);
color: #fff;
transform: scale(1.1);
}
.save-to-karakeep-btn.saved {
background: #10b981;
border-color: #10b981;
color: #fff;
}
/* Settings Gear Button in Header */
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.settings-btn {
background: #1a1a1a;
border: 1px solid var(--border-color);
color: var(--text-muted);
cursor: pointer;
border-radius: 8px;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s ease;
}
.settings-btn:hover {
border-color: var(--card-border-hover);
color: var(--text-main);
background: #252525;
}
/* Settings Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(5px);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.modal-overlay.active {
opacity: 1;
pointer-events: auto;
}
.modal-content {
background: #121212;
border: 1px solid var(--border-color);
border-radius: 16px;
width: 90%;
max-width: 440px;
padding: 24px;
box-shadow: 0 20px 40px rgba(0,0,0,0.5);
transform: translateY(-20px);
transition: transform 0.3s ease;
}
.modal-overlay.active .modal-content {
transform: translateY(0);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-main);
margin: 0;
}
.close-modal-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1.5rem;
padding: 0;
line-height: 1;
}
.close-modal-btn:hover {
color: var(--text-main);
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 6px;
font-weight: 500;
}
.form-input {
width: 100%;
background: #1c1c1c;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 12px;
color: #fff;
font-family: inherit;
font-size: 0.9rem;
box-sizing: border-box;
transition: border-color 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: var(--accent-color);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.btn-secondary {
background: none;
border: 1px solid var(--border-color);
color: var(--text-muted);
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.btn-secondary:hover {
border-color: var(--card-border-hover);
color: var(--text-main);
}
.btn-primary {
background: var(--accent-color);
border: 1px solid var(--accent-color);
color: #fff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(244, 116, 50, 0.2);
}
/* Toast system */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 10000;
}
.toast {
background: #1e1e1e;
border: 1px solid var(--border-color);
color: #fff;
padding: 12px 18px;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 500;
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
margin-top: 8px;
display: flex;
align-items: center;
gap: 10px;
transform: translateY(20px);
opacity: 0;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast-success {
border-color: #10b981;
}
.toast-error {
border-color: #ef4444;
}
</style>
</head>
<body>
<header>
<div class="header-title">
<span>📰</span>
<h1>Tech News Hub</h1>
</div>
<div class="header-actions">
<button class="settings-btn" id="theme-toggle-btn" title="Toggle Theme">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
Theme
</button>
<button class="settings-btn" id="settings-btn" title="Karakeep Integration">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
Karakeep
</button>
<button class="refresh-btn" id="refresh-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/>
</svg>
Refresh
</button>
</div>
</header>
<div class="filter-tabs" id="filter-tabs">
<button class="filter-tab active" data-source="all">All News</button>
</div>
<div id="loader" class="skeleton-grid" style="display: none;">
<!-- Skeletons generated dynamically -->
</div>
<div class="news-grid" id="news-grid">
<!-- News cards injected here -->
</div>
<!-- Karakeep Settings Modal -->
<div class="modal-overlay" id="settings-modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Karakeep Settings</h3>
<button class="close-modal-btn" id="close-modal-btn">&times;</button>
</div>
<div class="form-group">
<label class="form-label" for="karakeep-url">Karakeep Instance URL</label>
<input class="form-input" type="url" id="karakeep-url" placeholder="https://keep.yourdomain.com">
</div>
<div class="form-group">
<label class="form-label" for="karakeep-token">API Key / Token</label>
<input class="form-input" type="password" id="karakeep-token" placeholder="Paste your API key here">
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 6px;">
Generated under <strong>Settings ➔ API Keys</strong> inside Karakeep.
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" id="cancel-settings-btn">Cancel</button>
<button class="btn-primary" id="save-settings-btn">Save Settings</button>
</div>
</div>
</div>
<!-- Toast notifications -->
<div class="toast-container" id="toast-container"></div>
<script>
// List of feed definitions
const FEEDS = [
{ name: "Selfh.st", url: "https://selfh.st/rss/" },
{ name: "Hacker News", url: "https://hnrss.org/frontpage" },
{ name: "How-To Geek", url: "https://www.howtogeek.com/feed/news/" },
{ name: "XDA Developers", url: "https://www.xda-developers.com/feed/news/" },
{ name: "MakeUseOf", url: "https://www.makeuseof.com/feed/category/news/" },
{ name: "Hackaday", url: "https://hackaday.com/blog/feed/" }
];
let allArticles = [];
let currentFilter = "all";
let debugLogs = [];
const newsGrid = document.getElementById("news-grid");
const loader = document.getElementById("loader");
const refreshBtn = document.getElementById("refresh-btn");
const filterTabsContainer = document.getElementById("filter-tabs");
function logDebug(msg) {
console.log("[NewsPortal] " + msg);
debugLogs.push(msg);
}
// Initialize filter tabs
function initFilters() {
FEEDS.forEach(feed => {
const btn = document.createElement("button");
btn.className = "filter-tab";
btn.dataset.source = feed.name;
btn.textContent = feed.name;
filterTabsContainer.appendChild(btn);
});
filterTabsContainer.addEventListener("click", (e) => {
if (e.target.classList.contains("filter-tab")) {
document.querySelectorAll(".filter-tab").forEach(tab => tab.classList.remove("active"));
e.target.classList.add("active");
currentFilter = e.target.dataset.source;
renderArticles();
}
});
}
// Helper to generate loader skeletons
function showLoader(show) {
if (show) {
newsGrid.style.display = "none";
loader.style.display = "grid";
loader.innerHTML = Array(6).fill(0).map(() => `
<div class="skeleton-card">
<div class="skeleton-img"></div>
<div class="skeleton-text">
<div class="skeleton-line short"></div>
<div class="skeleton-line long"></div>
<div class="skeleton-line mid"></div>
<div class="skeleton-line long"></div>
</div>
</div>
`).join('');
} else {
loader.style.display = "none";
newsGrid.style.display = "grid";
}
}
// Parses raw XML feed strings into Javascript articles
function parseXmlFeed(xmlText, sourceName) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
const items = xmlDoc.querySelectorAll("item");
const articles = [];
if (items && items.length > 0) {
items.forEach(item => {
const title = item.querySelector("title")?.textContent || "";
const link = item.querySelector("link")?.textContent || "";
const pubDateText = item.querySelector("pubDate")?.textContent || "";
const description = item.querySelector("description")?.textContent || "";
const contentEncoded = item.getElementsByTagName("content:encoded")[0]?.textContent || "";
let imageUrl = "";
// Check media content tag
const mediaContent = item.getElementsByTagName("media:content")[0] || item.querySelector("content");
if (mediaContent && mediaContent.getAttribute("url")) {
imageUrl = mediaContent.getAttribute("url");
}
// Check enclosure tag
const enclosure = item.querySelector("enclosure");
if (!imageUrl && enclosure && enclosure.getAttribute("url")) {
imageUrl = enclosure.getAttribute("url");
}
// Match img tag inside content or description, ignoring tracking pixels
if (!imageUrl && (contentEncoded || description)) {
const imgRegex = /<img[^>]+src="([^">]+)"[^>]*>/g;
let match;
while ((match = imgRegex.exec(contentEncoded || description)) !== null) {
const src = match[1];
const fullTag = match[0];
// Skip likely tracking pixels
if (fullTag.includes('width="1"') || fullTag.includes('height="1"') ||
src.includes('pixel') || src.includes('tracker') || src.includes('analytics') || src.includes('mf.gif')) {
continue;
}
imageUrl = src;
break;
}
}
let cleanDesc = description || contentEncoded;
cleanDesc = cleanDesc.replace(/<[^>]*>/g, '').trim();
if (cleanDesc.length > 180) {
cleanDesc = cleanDesc.substring(0, 180) + "...";
}
articles.push({
title: title,
link: link,
pubDate: new Date(pubDateText || Date.now()),
source: sourceName,
imageUrl: imageUrl,
description: cleanDesc
});
});
} else {
// Fallback to Atom entry tags
const entries = xmlDoc.querySelectorAll("entry");
entries.forEach(entry => {
const title = entry.querySelector("title")?.textContent || "";
const link = entry.querySelector("link")?.getAttribute("href") || entry.querySelector("link")?.textContent || "";
const pubDateText = entry.querySelector("published")?.textContent || entry.querySelector("updated")?.textContent || "";
const content = entry.querySelector("content")?.textContent || entry.querySelector("summary")?.textContent || "";
let imageUrl = "";
if (content) {
const match = content.match(/<img[^>]+src="([^">]+)"/);
if (match && match[1]) {
imageUrl = match[1];
}
}
let cleanDesc = content.replace(/<[^>]*>/g, '').trim();
if (cleanDesc.length > 180) {
cleanDesc = cleanDesc.substring(0, 180) + "...";
}
articles.push({
title: title,
link: link,
pubDate: new Date(pubDateText || Date.now()),
source: sourceName,
imageUrl: imageUrl,
description: cleanDesc
});
});
}
return articles;
}
// Fetch helper with a strict timeout to prevent slow APIs from hanging the page
async function fetchWithTimeout(resource, options = {}) {
const { timeout = 2500 } = options;
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(resource, {
...options,
signal: controller.signal
});
clearTimeout(id);
return response;
} catch (err) {
clearTimeout(id);
throw err;
}
}
// Fetch RSS items using multiple CORS proxies as fallbacks.
async function fetchFeed(feed) {
// 0. Special high-reliability direct API for Hacker News (no proxies needed)
if (feed.name === "Hacker News") {
try {
logDebug("Trying Algolia API for Hacker News...");
const res = await fetchWithTimeout("https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=15", { timeout: 3000 });
if (res.ok) {
const data = await res.json();
if (data.hits && data.hits.length > 0) {
logDebug("Algolia API success for Hacker News");
return data.hits.map(item => {
const link = item.url || `https://news.ycombinator.com/item?id=${item.objectID}`;
return {
title: item.title,
link: link,
pubDate: new Date(item.created_at || Date.now()),
source: "Hacker News",
imageUrl: "",
description: `${item.points} points by ${item.author} | ${item.num_comments} comments`
};
});
}
}
} catch (err) {
logDebug(`Algolia API failed for Hacker News: ${err.message}`);
}
}
// 1. Try rss2json API first (bypasses most Pi-hole proxy blocks & handles Cloudflare)
try {
logDebug(`Trying rss2json for ${feed.name}...`);
const res = await fetchWithTimeout(`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(feed.url)}`, { timeout: 4500 });
if (res.ok) {
const data = await res.json();
if (data.status === "ok" && data.items && data.items.length > 0) {
logDebug(`rss2json success for ${feed.name}`);
return data.items.map(item => {
let imageUrl = item.thumbnail || (item.enclosure && item.enclosure.link) || "";
let description = item.description || item.content || "";
if (!imageUrl) {
const imgRegex = /<img[^>]+src="([^">]+)"[^>]*>/g;
let match;
while ((match = imgRegex.exec(description)) !== null) {
const src = match[1];
const fullTag = match[0];
if (fullTag.includes('width="1"') || fullTag.includes('height="1"') ||
src.includes('pixel') || src.includes('tracker') || src.includes('mf.gif')) {
continue;
}
imageUrl = src;
break;
}
}
let cleanDesc = description.replace(/<[^>]*>/g, '').trim();
if (cleanDesc.length > 180) {
cleanDesc = cleanDesc.substring(0, 180) + "...";
}
return {
title: item.title,
link: item.link,
pubDate: new Date(item.pubDate || Date.now()),
source: feed.name,
imageUrl: imageUrl,
description: cleanDesc
};
});
}
}
} catch (err) {
logDebug(`rss2json failed for ${feed.name}: ${err.message}`);
}
// Build a list of proxy URLs to try in order as fallbacks
const proxies = [
`https://api.allorigins.win/raw?url=${encodeURIComponent(feed.url)}`,
`https://thingproxy.freeboard.io/fetch/${feed.url}`,
`https://corsproxy.io/?url=${encodeURIComponent(feed.url)}`
];
for (const proxyUrl of proxies) {
try {
logDebug(`Trying proxy for ${feed.name}: ${proxyUrl.split("?")[0]}...`);
const res = await fetchWithTimeout(proxyUrl, { timeout: 7000 });
if (!res.ok) throw new Error(`Status ${res.status}`);
const xmlText = await res.text();
if (!xmlText || xmlText.trim().length < 50) throw new Error("Empty response");
const parsed = parseXmlFeed(xmlText, feed.name);
if (parsed.length === 0) throw new Error("No items parsed from XML");
logDebug(`Proxy success for ${feed.name} (${parsed.length} items)`);
return parsed;
} catch (err) {
logDebug(`Proxy failed for ${feed.name}: ${err.message}`);
}
}
logDebug(`All proxies exhausted for ${feed.name}`);
return [];
}
// Load news from all sources asynchronously and incrementally
async function loadAllNews() {
debugLogs = [];
refreshBtn.disabled = true;
refreshBtn.textContent = "Loading...";
if (allArticles.length === 0) {
showLoader(true);
}
let activeFetchesCount = FEEDS.length;
let loadedAny = false;
FEEDS.forEach(async (feed) => {
try {
const items = await fetchFeed(feed);
if (items && items.length > 0) {
loadedAny = true;
// Filter out old articles from the same source to avoid duplication
allArticles = allArticles.filter(a => a.source !== feed.name);
// Append new items
allArticles = allArticles.concat(items);
// Re-sort by date
allArticles.sort((a, b) => b.pubDate - a.pubDate);
// Save to localStorage
localStorage.setItem("news_cache_v2", JSON.stringify(allArticles));
// Render immediately
renderArticles();
}
} catch (err) {
logDebug(`Error fetching ${feed.name}: ${err.message}`);
} finally {
activeFetchesCount--;
if (activeFetchesCount === 0) {
showLoader(false);
refreshBtn.disabled = false;
refreshBtn.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/>
</svg> Refresh
`;
// If we completed all fetches and still have nothing, renderArticles will show debug panel
if (!loadedAny && allArticles.length === 0) {
renderArticles();
}
}
}
});
}
// Convert date to relative time ("2 hours ago")
function timeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
let interval = Math.floor(seconds / 31536000);
if (interval >= 1) return `${interval}y ago`;
interval = Math.floor(seconds / 2592000);
if (interval >= 1) return `${interval}mo ago`;
interval = Math.floor(seconds / 86400);
if (interval >= 1) return `${interval}d ago`;
interval = Math.floor(seconds / 3600);
if (interval >= 1) return `${interval}h ago`;
interval = Math.floor(seconds / 60);
if (interval >= 1) return `${interval}m ago`;
return "just now";
}
// Render articles to screen
function renderArticles() {
// Render debug logs ONLY if absolutely no articles were loaded across ALL sources
if (allArticles.length === 0) {
const html = `
<div class="error-panel">
<strong>⚠️ No articles were loaded. This is usually due to CORS proxy limits or local network blocks.</strong><br><br>
<strong>Technical Log:</strong>
<pre style="margin-top: 8px; font-family: monospace; font-size: 0.8rem; line-height: 1.4; color: #ffcccc; white-space: pre-wrap;">${debugLogs.join("\n")}</pre>
</div>
`;
newsGrid.innerHTML = html;
return;
}
const filtered = currentFilter === "all"
? allArticles
: allArticles.filter(a => a.source === currentFilter);
if (filtered.length === 0) {
newsGrid.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: var(--text-muted);">
No articles loaded for ${currentFilter} yet. Tap refresh to reload.
</div>
`;
return;
}
newsGrid.innerHTML = filtered.map(art => {
const hasImg = art.imageUrl && art.imageUrl.startsWith("http");
const imageHtml = hasImg
? `<img class="card-image" src="${art.imageUrl}" alt="${art.title}" loading="lazy">`
: `<div class="card-fallback-bg"><span class="card-fallback-text">${art.source.substring(0, 3)}</span></div>`;
return `
<a class="news-card" href="${art.link}" target="_blank" rel="noopener noreferrer">
<div class="card-image-container">
${imageHtml}
<button class="save-to-karakeep-btn" data-url="${art.link}" title="Save to Karakeep">
<svg class="bookmark-icon" viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>
</button>
</div>
<div class="card-content">
<div>
<div class="card-meta">
<span class="source-badge">${art.source}</span>
<span class="pub-date">${timeAgo(art.pubDate)}</span>
</div>
<h2 class="card-title">${art.title}</h2>
</div>
<p class="card-desc">${art.description || 'No description available.'}</p>
</div>
</a>
`;
}).join('');
}
// Setup and initial load
initFilters();
// Load cached articles immediately
try {
const cached = localStorage.getItem("news_cache_v2");
if (cached) {
allArticles = JSON.parse(cached);
// Convert date strings back to Date objects
allArticles.forEach(a => a.pubDate = new Date(a.pubDate));
renderArticles();
}
} catch (e) {
console.error("Error reading cache:", e);
}
loadAllNews();
refreshBtn.addEventListener("click", loadAllNews);
// Theme Switcher JS
const themeToggleBtn = document.getElementById("theme-toggle-btn");
const currentTheme = localStorage.getItem("news_theme") || "default";
if (currentTheme === "jonah") {
document.documentElement.setAttribute("data-theme", "jonah");
}
themeToggleBtn.addEventListener("click", () => {
const isJonah = document.documentElement.getAttribute("data-theme") === "jonah";
if (isJonah) {
document.documentElement.removeAttribute("data-theme");
localStorage.setItem("news_theme", "default");
} else {
document.documentElement.setAttribute("data-theme", "jonah");
localStorage.setItem("news_theme", "jonah");
}
});
// Karakeep Integration JS
const settingsModal = document.getElementById("settings-modal");
const settingsBtn = document.getElementById("settings-btn");
const closeModalBtn = document.getElementById("close-modal-btn");
const cancelSettingsBtn = document.getElementById("cancel-settings-btn");
const saveSettingsBtn = document.getElementById("save-settings-btn");
const karakeepUrlInput = document.getElementById("karakeep-url");
const karakeepTokenInput = document.getElementById("karakeep-token");
// Load saved settings
karakeepUrlInput.value = localStorage.getItem("karakeep_url") || "";
karakeepTokenInput.value = localStorage.getItem("karakeep_token") || "";
settingsBtn.addEventListener("click", () => settingsModal.classList.add("active"));
closeModalBtn.addEventListener("click", () => settingsModal.classList.remove("active"));
cancelSettingsBtn.addEventListener("click", () => settingsModal.classList.remove("active"));
saveSettingsBtn.addEventListener("click", () => {
let url = karakeepUrlInput.value.trim();
if (url && !url.startsWith("http")) {
url = "https://" + url;
}
localStorage.setItem("karakeep_url", url);
localStorage.setItem("karakeep_token", karakeepTokenInput.value.trim());
settingsModal.classList.remove("active");
showToast("Settings saved successfully!", "success");
});
function showToast(message, type = "info") {
const container = document.getElementById("toast-container");
const toast = document.createElement("div");
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
// Force reflow
toast.offsetHeight;
toast.classList.add("show");
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Bookmark button click handler (event delegation)
document.getElementById("news-grid").addEventListener("click", async (e) => {
const btn = e.target.closest(".save-to-karakeep-btn");
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const url = btn.dataset.url;
const karakeepUrl = localStorage.getItem("karakeep_url");
const token = localStorage.getItem("karakeep_token");
if (!karakeepUrl || !token) {
showToast("Please configure Karakeep settings first!", "error");
settingsModal.classList.add("active");
return;
}
btn.disabled = true;
btn.style.opacity = "0.7";
showToast("Saving to Karakeep...", "info");
try {
const res = await fetch(`${karakeepUrl.replace(/\/$/, "")}/api/v1/bookmarks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
type: "link",
url: url
})
});
if (res.ok) {
btn.classList.add("saved");
btn.disabled = false;
btn.style.opacity = "1";
btn.innerHTML = `
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>
`;
showToast("Saved to Karakeep!", "success");
} else {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || `Server returned status ${res.status}`);
}
} catch (err) {
btn.disabled = false;
btn.style.opacity = "1";
showToast(`Failed: ${err.message}`, "error");
}
});
</script>
</body>
</html>