1211 lines
38 KiB
HTML
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">×</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>
|