Files
clawfort/frontend/index.html
Santhosh Janardhanan 679561bcdb
Some checks failed
quality-gates / lint-and-test (push) Has been cancelled
quality-gates / security-scan (push) Has been cancelled
First deployment
2026-02-13 09:14:04 -05:00

1499 lines
66 KiB
HTML

<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="ClawFort brings you the latest AI news with hourly updates, concise summaries, and source links.">
<meta name="robots" content="index,follow,max-image-preview:large">
<meta name="referrer" content="strict-origin-when-cross-origin">
<meta name="theme-color" content="#0f172a">
<meta property="og:type" content="website">
<meta property="og:site_name" content="ClawFort">
<meta property="og:title" content="ClawFort - AI News">
<meta property="og:description" content="The latest AI news, refreshed hourly with concise summaries and source attribution.">
<meta property="og:url" content="/" id="meta-og-url">
<meta property="og:image" content="/static/images/placeholder.png" id="meta-og-image">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ClawFort - AI News">
<meta name="twitter:description" content="The latest AI news, refreshed hourly with concise summaries and source attribution.">
<meta name="twitter:image" content="/static/images/placeholder.png" id="meta-twitter-image">
<link rel="canonical" href="/" id="canonical-link">
<link rel="dns-prefetch" href="//fonts.googleapis.com">
<link rel="dns-prefetch" href="//fonts.gstatic.com">
<title>ClawFort AI News</title>
<link rel="icon" type="image/svg+xml" href="/static/images/favicon-ai.svg">
<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=Inter:wght@400;500;600;700;800;900&family=Noto+Sans+Tamil:wght@400;500;600;700&family=Noto+Sans+Malayalam:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
cf: {
50: '#f0f4ff', 100: '#dbe4ff', 200: '#bac8ff',
400: '#748ffc', 500: '#5c7cfa', 600: '#4c6ef5',
700: '#4263eb', 900: '#1e2a5e', 950: '#0f172a'
}
},
fontFamily: {
sans: ['Inter', 'Noto Sans Tamil', 'Noto Sans Malayalam', 'system-ui', 'sans-serif']
}
}
}
}
</script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script id="structured-data-graph" type="application/ld+json">{"@context":"https://schema.org","@graph":[]}</script>
<style>
html { scroll-behavior: smooth; }
[x-cloak] { display: none !important; }
@keyframes fadeUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp 0.6s ease-out forwards; }
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
.skeleton { background: linear-gradient(90deg, #1e293b 25%, #334155 50%, #1e293b 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
:root {
--cf-bg: #0f172a;
--cf-text: #f1f5f9;
--cf-text-strong: #e2e8f0;
--cf-text-muted: #94a3b8;
--cf-header-bg: rgba(15, 23, 42, 0.85);
--cf-card-bg: #1e293b;
--cf-modal-bg: #0f172a;
--cf-select-bg: #1e293b;
--cf-select-text: #e2e8f0;
--cf-select-border: rgba(148, 163, 184, 0.3);
}
html[data-theme='light'] {
--cf-bg: #f8fafc;
--cf-text: #0f172a;
--cf-text-strong: #0f172a;
--cf-text-muted: #475569;
--cf-header-bg: rgba(248, 250, 252, 0.92);
--cf-card-bg: #ffffff;
--cf-modal-bg: #ffffff;
--cf-select-bg: #ffffff;
--cf-select-text: #0f172a;
--cf-select-border: rgba(15, 23, 42, 0.2);
}
html[data-theme='dark'] {
--cf-bg: #0f172a;
--cf-text: #f1f5f9;
--cf-text-strong: #e2e8f0;
--cf-text-muted: #94a3b8;
--cf-header-bg: rgba(15, 23, 42, 0.85);
--cf-card-bg: #1e293b;
--cf-modal-bg: #0f172a;
--cf-select-bg: #1e293b;
--cf-select-text: #e2e8f0;
--cf-select-border: rgba(148, 163, 184, 0.3);
}
html[data-theme='contrast'] {
--cf-bg: #000000;
--cf-text: #ffffff;
--cf-text-strong: #ffffff;
--cf-text-muted: #f8fafc;
--cf-header-bg: rgba(0, 0, 0, 0.96);
--cf-card-bg: #000000;
--cf-modal-bg: #000000;
--cf-select-bg: #000000;
--cf-select-text: #ffffff;
--cf-select-border: rgba(255, 255, 255, 0.55);
}
.cf-body { background: var(--cf-bg); color: var(--cf-text); padding-bottom: 78px; }
.cf-header { background: var(--cf-header-bg); }
.cf-header-top { box-shadow: none; border-color: rgba(148, 163, 184, 0.12); }
.cf-header-scrolled {
box-shadow: 0 10px 28px rgba(2, 6, 23, 0.24);
border-color: rgba(148, 163, 184, 0.28);
}
.cf-card { background: var(--cf-card-bg) !important; }
.cf-modal { background: var(--cf-modal-bg); }
.cf-select {
background: var(--cf-select-bg);
color: var(--cf-select-text);
border: 1px solid var(--cf-select-border);
}
.theme-menu-panel {
background: var(--cf-card-bg);
border-color: var(--cf-select-border);
}
.theme-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
border-radius: 6px;
padding: 6px 8px;
color: var(--cf-text-strong);
text-align: left;
font-size: 13px;
}
.theme-menu-item:hover { background: rgba(92, 124, 250, 0.15); }
.hero-overlay {
background: linear-gradient(to top, rgba(2, 6, 23, 0.97), rgba(15, 23, 42, 0.75), rgba(15, 23, 42, 0.35), transparent);
}
.hero-title { color: #e2e8f0; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.55); }
.hero-summary { color: #cbd5e1; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); }
.hero-meta { color: #e2e8f0; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); }
.hero-latest-pill {
background: rgba(59, 130, 246, 0.2);
color: #dbeafe;
border: 1px solid rgba(147, 197, 253, 0.45);
}
.hero-time-pill {
background: rgba(15, 23, 42, 0.55);
color: #e2e8f0;
border: 1px solid rgba(148, 163, 184, 0.35);
padding: 3px 8px;
border-radius: 9999px;
}
.tldr-shimmer {
height: 12px;
width: 85%;
border-radius: 9999px;
}
.news-card-title { color: var(--cf-text-strong); }
.news-card-summary { color: var(--cf-text-muted); }
.news-card-meta { color: var(--cf-text-muted); }
.news-card-btn { color: #dbe4ff; }
.modal-article-title { color: var(--cf-text-strong); }
.modal-section-title { color: #5c7cfa; }
.modal-body-text { color: var(--cf-text-strong); }
.modal-citation { color: var(--cf-text-muted); }
.modal-powered { color: var(--cf-text-muted); }
.modal-close-btn { color: var(--cf-text-muted); }
.modal-close-btn:hover { color: var(--cf-text-strong); }
.modal-cta {
background: rgba(92, 124, 250, 0.18);
color: #dbe4ff;
}
.modal-cta:hover { background: rgba(92, 124, 250, 0.28); }
html[data-theme='light'] .news-card {
border-color: rgba(15, 23, 42, 0.14) !important;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.06);
}
html[data-theme='light'] .hero-title,
html[data-theme='light'] .hero-summary,
html[data-theme='light'] .hero-meta {
color: #f8fafc;
}
html[data-theme='light'] .hero-overlay {
background: linear-gradient(to top, rgba(15, 23, 42, 0.92), rgba(30, 41, 59, 0.58), rgba(30, 41, 59, 0.2), transparent);
}
html[data-theme='light'] .hero-latest-pill {
background: rgba(37, 99, 235, 0.24);
border-color: rgba(37, 99, 235, 0.55);
color: #eff6ff;
}
html[data-theme='light'] .hero-time-pill {
background: rgba(15, 23, 42, 0.52);
border-color: rgba(226, 232, 240, 0.35);
color: #f8fafc;
}
html[data-theme='light'] .news-card-btn { color: #1e3a8a; }
html[data-theme='light'] .modal-cta {
color: #1e3a8a;
background: rgba(59, 91, 219, 0.14);
}
html[data-theme='light'] .modal-cta:hover { background: rgba(59, 91, 219, 0.22); }
html[data-theme='light'] .text-gray-500,
html[data-theme='light'] .text-gray-400 { color: #475569 !important; }
.theme-btn {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--cf-select-border);
background: var(--cf-select-bg);
color: var(--cf-select-text);
font-size: 14px;
line-height: 1;
}
.theme-btn[data-active='true'] {
border-color: #5c7cfa;
box-shadow: 0 0 0 1px #5c7cfa inset;
}
html[data-lang='ta'] .news-card-summary,
html[data-lang='ml'] .news-card-summary,
html[data-lang='ta'] .hero-summary,
html[data-lang='ml'] .hero-summary,
html[data-lang='ta'] .modal-body-text,
html[data-lang='ml'] .modal-body-text {
font-size: 1.02rem;
line-height: 1.78;
letter-spacing: 0.01em;
}
html[data-lang='ta'] .hero-title,
html[data-lang='ml'] .hero-title {
text-shadow: 0 3px 12px rgba(0, 0, 0, 0.75);
}
html[data-lang='ta'] .hero-summary,
html[data-lang='ml'] .hero-summary {
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.7);
}
.share-icon-btn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(92, 124, 250, 0.14);
color: #dbeafe;
transition: background 180ms ease;
}
.share-icon-btn:hover { background: rgba(92, 124, 250, 0.25); }
html[data-theme='light'] .share-icon-btn {
color: #1d4ed8;
border-color: rgba(37, 99, 235, 0.45);
background: rgba(59, 130, 246, 0.12);
}
html[data-theme='light'] .share-icon-btn:hover {
background: rgba(59, 130, 246, 0.2);
}
.footer-link { text-decoration: underline; text-underline-offset: 2px; }
.footer-link:hover { color: #dbeafe; }
.site-footer {
background: color-mix(in srgb, var(--cf-bg) 92%, transparent);
backdrop-filter: blur(10px);
}
.back-to-top-island {
position: fixed;
right: 14px;
bottom: 88px;
z-index: 55;
border-radius: 9999px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.88);
color: #dbeafe;
min-width: 44px;
min-height: 44px;
padding: 0 13px;
font-size: 12px;
font-weight: 700;
line-height: 1;
box-shadow: 0 12px 24px rgba(2, 6, 23, 0.35);
}
html[data-theme='light'] .back-to-top-island {
background: rgba(248, 250, 252, 0.96);
color: #1e3a8a;
border-color: rgba(37, 99, 235, 0.35);
}
.contact-hint {
position: fixed;
z-index: 60;
max-width: 280px;
background: rgba(15, 23, 42, 0.96);
color: #e2e8f0;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.5;
pointer-events: none;
box-shadow: 0 8px 22px rgba(2, 6, 23, 0.45);
}
*:focus-visible {
outline: 2px solid #5c7cfa;
outline-offset: 2px;
}
@media (max-width: 640px) {
.theme-btn { width: 26px; height: 26px; }
.cf-body { padding-bottom: 84px; }
html[data-lang='ta'] .hero-title,
html[data-lang='ml'] .hero-title {
font-size: 1.9rem;
line-height: 1.26;
letter-spacing: 0.01em;
}
html[data-lang='ta'] .hero-summary,
html[data-lang='ml'] .hero-summary {
font-size: 1.08rem;
line-height: 1.86;
letter-spacing: 0.015em;
}
.back-to-top-island {
right: 10px;
bottom: 82px;
}
}
</style>
</head>
<body class="font-sans min-h-screen overflow-x-hidden cf-body">
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 bg-cf-500 text-white px-3 py-2 rounded">Skip to content</a>
<header x-data="headerFx()" x-init="init()" :class="scrolled ? 'cf-header-scrolled' : 'cf-header-top'" class="sticky top-0 z-50 backdrop-blur-lg border-b border-white/5 cf-header transition-all duration-300">
<div :class="scrolled ? 'h-14' : 'h-16'" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between transition-all duration-300">
<a href="/" class="flex items-center gap-2.5 group">
<svg class="w-8 h-8 text-cf-500 group-hover:text-cf-400 transition-colors" viewBox="0 0 32 32" fill="none">
<path d="M16 2L4 8v8c0 7.7 5.1 14.9 12 16 6.9-1.1 12-8.3 12-16V8L16 2z" fill="currentColor" fill-opacity="0.15" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 14l2 2 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="text-xl font-bold tracking-tight">Claw<span class="text-cf-500">Fort</span></span>
</a>
<div class="flex items-center gap-3">
<label for="language-select" class="text-xs text-gray-400 hidden sm:block">Language</label>
<select id="language-select"
class="cf-select text-xs rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-cf-500"
onchange="setPreferredLanguage(this.value)">
<option value="en">English</option>
<option value="ta">Tamil</option>
<option value="ml">Malayalam</option>
</select>
<div class="relative" id="theme-menu-root">
<button id="theme-menu-button" type="button" class="theme-btn" aria-haspopup="menu" aria-expanded="false" aria-label="Open theme menu" title="Theme menu" onclick="toggleThemeMenu()">&#9788;</button>
<div id="theme-menu" class="hidden absolute right-0 mt-2 w-44 rounded-lg border theme-menu-panel p-2 z-50" role="menu" aria-label="Theme options">
<button type="button" data-theme-option="system" onclick="setThemePreference('system'); closeThemeMenu();" class="theme-menu-item" role="menuitem" aria-label="Use system theme">&#128421; <span>System</span></button>
<button type="button" data-theme-option="light" onclick="setThemePreference('light'); closeThemeMenu();" class="theme-menu-item" role="menuitem" aria-label="Use light theme">&#9728; <span>Light</span></button>
<button type="button" data-theme-option="dark" onclick="setThemePreference('dark'); closeThemeMenu();" class="theme-menu-item" role="menuitem" aria-label="Use dark theme">&#9790; <span>Dark</span></button>
<button type="button" data-theme-option="contrast" onclick="setThemePreference('contrast'); closeThemeMenu();" class="theme-menu-item" role="menuitem" aria-label="Use high contrast theme">&#9681; <span>High Contrast</span></button>
</div>
</div>
<span class="text-xs text-gray-500 hidden lg:block">AI News — Updated Hourly</span>
</div>
</div>
</header>
<main id="main-content">
<section x-data="heroBlock()" x-init="init()" class="relative">
<template x-if="loading">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="skeleton rounded-2xl h-[400px] w-full"></div>
<div class="mt-6 space-y-3">
<div class="skeleton h-10 w-3/4 rounded-lg"></div>
<div class="skeleton h-5 w-full rounded-lg"></div>
<div class="skeleton h-5 w-2/3 rounded-lg"></div>
</div>
</div>
</template>
<template x-if="!loading && item">
<article class="fade-up" itemscope itemtype="https://schema.org/NewsArticle" :id="'news-' + item.id">
<meta itemprop="headline" :content="item.headline">
<meta itemprop="description" :content="item.summary">
<meta itemprop="datePublished" :content="item.published_at">
<meta itemprop="dateModified" :content="item.created_at || item.published_at">
<meta itemprop="image" :content="toAbsoluteUrl(preferredImage(item))">
<meta itemprop="url" :content="articlePermalink(item)">
<meta itemprop="inLanguage" :content="item.language || 'en'">
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<div class="relative rounded-2xl overflow-hidden group">
<div x-show="heroImageLoading" class="absolute inset-0 skeleton"></div>
<img :src="preferredImage(item)" :alt="item.headline"
width="1200" height="480" decoding="async" fetchpriority="high"
class="w-full h-[300px] sm:h-[400px] lg:h-[480px] object-cover transition-transform duration-700 group-hover:scale-105"
@load="heroImageLoading = false"
@error="$el.src='/static/images/placeholder.png'; heroImageLoading = false">
<div class="absolute inset-0 hero-overlay"></div>
<div class="absolute bottom-0 left-0 right-0 p-6 sm:p-10">
<div class="flex items-center gap-3 mb-3">
<span class="px-2.5 py-1 text-xs font-semibold rounded-full hero-latest-pill">LATEST</span>
<span class="text-sm hero-time-pill" x-text="timeAgo(item.published_at)"></span>
</div>
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-extrabold leading-tight mb-3 max-w-4xl hero-title" x-text="item.headline"></h1>
<p class="text-base sm:text-lg max-w-3xl line-clamp-3 mb-4 hero-summary" x-text="item.summary"></p>
<div class="flex flex-wrap items-center gap-4 text-sm hero-meta">
<button class="px-3 py-1.5 rounded-md bg-cf-500/20 text-cf-300 hover:bg-cf-500/30 transition-colors"
@click="trackEvent('hero-cta-click', { article_id: item.id, article_title: item.headline }); window.dispatchEvent(new CustomEvent('open-summary', { detail: item }))">
Read TL;DR
</button>
<a :href="item.source_url" target="_blank" rel="noopener"
class="hover:text-cf-400 transition-colors"
@click="trackEvent('hero-source-click')"
x-show="item.source_url">
Via: <span x-text="extractDomain(item.source_url)" class="underline underline-offset-2"></span>
</a>
<span x-show="item.image_credit" x-text="'Image: ' + item.image_credit"></span>
</div>
</div>
</div>
</div>
</article>
</template>
<template x-if="!loading && !item">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
<div class="text-6xl mb-4">🤖</div>
<h2 class="text-2xl font-bold mb-2">No News Yet</h2>
<p class="text-gray-400">Check back soon — news is fetched hourly.</p>
</div>
</template>
</section>
<section x-data="newsFeed()" x-init="init()" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<h2 class="text-xl font-bold mb-6 flex items-center gap-2">
<span class="w-1 h-6 bg-cf-500 rounded-full"></span> Recent News
</h2>
<template x-if="initialLoading">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="i in 6" :key="i">
<div class="rounded-xl overflow-hidden">
<div class="skeleton h-48 w-full"></div>
<div class="p-4 bg-[#1e293b] space-y-3">
<div class="skeleton h-5 w-full rounded"></div>
<div class="skeleton h-4 w-3/4 rounded"></div>
<div class="skeleton h-3 w-1/2 rounded"></div>
</div>
</div>
</template>
</div>
</template>
<div x-show="!initialLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="item in items" :key="item.id">
<article class="rounded-xl overflow-hidden border border-white/5 hover:border-cf-500/30 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-cf-500/5 group cf-card news-card" itemscope itemtype="https://schema.org/NewsArticle" :id="'news-' + item.id">
<meta itemprop="headline" :content="item.headline">
<meta itemprop="description" :content="item.summary">
<meta itemprop="datePublished" :content="item.published_at">
<meta itemprop="dateModified" :content="item.created_at || item.published_at">
<meta itemprop="image" :content="toAbsoluteUrl(preferredImage(item))">
<meta itemprop="url" :content="articlePermalink(item)">
<meta itemprop="inLanguage" :content="item.language || 'en'">
<div class="relative h-48 overflow-hidden">
<div x-show="isImageLoading(item.id)" class="absolute inset-0 skeleton"></div>
<img :src="preferredImage(item)" :alt="item.headline" loading="lazy"
width="640" height="384" decoding="async" fetchpriority="low"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
@load="markImageLoaded(item.id)"
@error="handleImageError(item.id, $el)">
<div class="absolute inset-0 bg-gradient-to-t from-[#1e293b] to-transparent opacity-60"></div>
</div>
<div class="p-5">
<h3 class="font-bold text-base mb-2 line-clamp-2 group-hover:text-cf-400 transition-colors news-card-title" x-text="item.headline"></h3>
<p class="text-sm line-clamp-2 mb-3 news-card-summary" x-text="item.summary"></p>
<div class="flex items-center justify-between text-xs mb-3 news-card-meta">
<a :href="item.source_url" target="_blank" rel="noopener"
class="hover:text-cf-400 transition-colors truncate max-w-[60%]"
@click.stop="trackEvent('source-link-click')"
x-show="item.source_url"
x-text="extractDomain(item.source_url)"></a>
<span x-text="timeAgo(item.published_at)"></span>
</div>
<button @click="trackEvent('feed-cta-click', { article_id: item.id, article_title: item.headline }); openSummary(item)"
class="w-full text-center text-xs font-semibold rounded-md px-3 py-2 bg-cf-500/15 hover:bg-cf-500/25 transition-colors news-card-btn">
Read TL;DR
</button>
</div>
</article>
</template>
</div>
<div x-show="modalOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center" @keydown.escape.window="closeSummary()">
<div class="absolute inset-0 bg-black/70" @click="closeSummary()"></div>
<div role="dialog" aria-modal="true" aria-label="Article summary" class="relative w-full sm:w-[92vw] lg:w-[70vw] xl:w-[60vw] 2xl:w-[50vw] max-w-[1200px] mx-4 max-h-[96vh] overflow-auto rounded-xl border border-white/10 bg-[#0f172a]">
<div class="p-6 space-y-5 cf-modal" x-show="modalItem">
<div class="flex justify-end">
<button @click="closeSummary()" aria-label="Close summary modal" class="transition-colors modal-close-btn">Close</button>
</div>
<div class="relative">
<div x-show="modalImageLoading" class="absolute inset-0 skeleton rounded-lg"></div>
<img :src="modalItem?.summary_image_url || modalItem?.image_url || '/static/images/placeholder.png'"
:alt="modalItem?.headline || 'Summary image'"
loading="lazy" decoding="async" width="1024" height="576"
class="w-full h-56 sm:h-72 object-cover rounded-lg"
@load="modalImageLoading = false"
@error="$el.src='/static/images/placeholder.png'; modalImageLoading = false">
</div>
<h2 class="text-xl sm:text-2xl font-bold leading-tight modal-article-title" x-text="modalItem?.headline || ''"></h2>
<div>
<h3 class="text-sm uppercase tracking-wide font-semibold mb-2 modal-section-title">TL;DR</h3>
<div x-show="modalTldrLoading" class="space-y-2" aria-hidden="true">
<div class="skeleton tldr-shimmer"></div>
<div class="skeleton tldr-shimmer w-[70%]"></div>
</div>
<ul class="list-disc pl-5 space-y-1 text-sm modal-body-text" x-show="!modalTldrLoading && modalItem?.tldr_points && modalItem.tldr_points.length > 0">
<template x-for="(point, idx) in (modalItem?.tldr_points || [])" :key="idx">
<li x-text="point"></li>
</template>
</ul>
</div>
<div>
<h3 class="text-sm uppercase tracking-wide font-semibold mb-2 modal-section-title">Summary</h3>
<p class="text-sm leading-relaxed modal-body-text" x-text="modalItem?.summary_body || modalItem?.summary || ''"></p>
</div>
<div>
<h3 class="text-sm uppercase tracking-wide font-semibold mb-2 modal-section-title">Source and Citation</h3>
<p class="text-xs mb-3 modal-citation" x-text="modalItem?.source_citation || 'Original source' "></p>
<a :href="modalItem?.source_url || '#'" target="_blank" rel="noopener"
class="inline-block px-3 py-2 rounded-md text-sm font-semibold modal-cta"
@click="trackSummarySource(modalItem)">
Read Full Article
</a>
</div>
<div>
<h3 class="text-sm uppercase tracking-wide font-semibold mb-2 modal-section-title">Share</h3>
<div class="flex items-center gap-2">
<a :href="shareLink('x', modalItem)" target="_blank" rel="noopener" class="share-icon-btn" aria-label="Share on X" title="Share on X">
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true"><path fill="currentColor" d="M18.9 2H22l-6.8 7.8L23 22h-6.2l-4.9-6.4L6.2 22H3l7.3-8.4L1 2h6.3l4.4 5.8L18.9 2z"/></svg>
</a>
<a :href="shareLink('whatsapp', modalItem)" target="_blank" rel="noopener" class="share-icon-btn" aria-label="Share on WhatsApp" title="Share on WhatsApp">
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true"><path fill="currentColor" d="M20.5 3.5A11.8 11.8 0 0 0 12.1 0C5.5 0 .1 5.3.1 11.9c0 2.1.5 4.1 1.6 5.9L0 24l6.4-1.7a12 12 0 0 0 5.7 1.4h.1c6.6 0 11.9-5.3 11.9-11.9 0-3.2-1.3-6.2-3.6-8.3zM12.2 21.7h-.1a9.9 9.9 0 0 1-5-1.4l-.4-.2-3.8 1 1-3.7-.2-.4a9.8 9.8 0 0 1-1.5-5.2c0-5.4 4.4-9.8 9.9-9.8 2.6 0 5.1 1 6.9 2.9a9.7 9.7 0 0 1 2.8 6.9c0 5.4-4.4 9.9-9.8 9.9zm5.4-7.3c-.3-.1-1.8-.9-2.1-1-.3-.1-.5-.1-.7.2-.2.3-.8 1-1 1.2-.2.2-.3.2-.6.1-1.6-.8-2.6-1.5-3.7-3.3-.3-.5.3-.5 1-1.8.1-.2.1-.4 0-.6l-1-2.4c-.2-.6-.5-.5-.7-.5h-.6c-.2 0-.6.1-.9.4-.3.3-1.2 1.1-1.2 2.8 0 1.6 1.2 3.2 1.4 3.4.2.2 2.3 3.6 5.6 5 .8.3 1.4.5 1.9.7.8.3 1.5.2 2 .1.6-.1 1.8-.8 2-1.5.2-.7.2-1.4.1-1.5-.1-.1-.3-.2-.6-.3z"/></svg>
</a>
<a :href="shareLink('linkedin', modalItem)" target="_blank" rel="noopener" class="share-icon-btn" aria-label="Share on LinkedIn" title="Share on LinkedIn">
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true"><path fill="currentColor" d="M4.98 3.5a2.5 2.5 0 1 1 0 5.001A2.5 2.5 0 0 1 4.98 3.5zM3 9h4v12H3zM10 9h3.8v1.7h.1c.5-1 1.8-2.1 3.7-2.1 4 0 4.7 2.6 4.7 6V21h-4v-5.4c0-1.3 0-2.9-1.8-2.9s-2.1 1.4-2.1 2.8V21h-4V9z"/></svg>
</a>
<button type="button" class="share-icon-btn" aria-label="Copy article link" title="Copy article link"
@click="copyShareLink(modalItem)">
<svg x-show="copyStatus !== 'copied'" viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true"><path fill="currentColor" d="M16 1H6a2 2 0 0 0-2 2v12h2V3h10V1zm3 4H10a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H10V7h9v14z"/></svg>
<svg x-show="copyStatus === 'copied'" viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true"><path fill="currentColor" d="M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/></svg>
</button>
</div>
<p class="text-xs text-gray-400 mt-2" x-show="copyStatus === 'copied'">Permalink copied.</p>
</div>
<p class="text-xs modal-powered">Powered by Perplexity</p>
</div>
</div>
</div>
<div x-show="loadingMore" class="flex justify-center py-8">
<div class="w-8 h-8 border-2 border-cf-500/30 border-t-cf-500 rounded-full animate-spin"></div>
</div>
<div x-show="!hasMore && items.length > 0 && !initialLoading" class="text-center py-12 text-gray-500">
<div class="text-3xl mb-2"></div>
<p class="font-medium">You're all caught up!</p>
</div>
<div x-ref="sentinel" class="h-4"></div>
</section>
<div class="fixed left-0 right-0 pointer-events-none" style="top:25%" x-data x-intersect:enter="trackDepth(25)"></div>
<div class="fixed left-0 right-0 pointer-events-none" style="top:50%" x-data x-intersect:enter="trackDepth(50)"></div>
<div class="fixed left-0 right-0 pointer-events-none" style="top:75%" x-data x-intersect:enter="trackDepth(75)"></div>
</main>
<section x-data="policyDisclosures()" x-init="init()">
<div x-show="openType" x-cloak class="fixed inset-0 z-[60] flex items-center justify-center" @keydown.window="onKeydown($event)">
<div class="absolute inset-0 bg-black/70" @click="close()"></div>
<div role="dialog" aria-modal="true" :aria-label="openType === 'terms' ? 'Terms of Use' : 'Attribution'"
class="relative w-full sm:w-[92vw] lg:w-[70vw] xl:w-[60vw] 2xl:w-[50vw] max-w-[1200px] mx-4 max-h-[96vh] overflow-auto rounded-xl border border-white/10 bg-[#0f172a]">
<div class="p-6 space-y-5 cf-modal">
<div class="flex justify-end">
<button type="button" @click="close()" aria-label="Close policy modal" class="transition-colors modal-close-btn">Close</button>
</div>
<template x-if="openType === 'terms'">
<div>
<h2 class="text-xl sm:text-2xl font-bold leading-tight modal-article-title">Terms of Use</h2>
<div class="space-y-3 text-sm leading-relaxed modal-body-text mt-3">
<p>The information shown on this site is provided for general informational use only. It is not independently verified by the site owner.</p>
<p>Any use of this content is entirely at your own risk. You are responsible for validating facts, citations, and suitability before relying on or redistributing the information.</p>
<p>Content may originate from external and AI-generated sources and may include errors, omissions, or outdated material.</p>
</div>
</div>
</template>
<template x-if="openType === 'attribution'">
<div>
<h2 class="text-xl sm:text-2xl font-bold leading-tight modal-article-title">Attribution and Ownership Disclaimer</h2>
<div class="space-y-3 text-sm leading-relaxed modal-body-text mt-3">
<p>None of the content presented on this site is created by the owner as an individual person.</p>
<p>The content is AI-generated and automatically assembled from external sources and generated summaries.</p>
<p>The owner does not claim personal authorship, editorial ownership, or direct responsibility for generated statements beyond operating the site interface.</p>
</div>
</div>
</template>
</div>
</div>
</div>
</section>
<button x-data="backToTopIsland()" x-init="init()" x-show="visible" x-cloak @click="scrollTop()"
class="back-to-top-island" aria-label="Back to top" title="Back to top">
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true"><path fill="currentColor" d="M12 5 5 12h4v7h6v-7h4z"/></svg>
</button>
<footer x-data="footerEnhancements()" x-init="init()" class="site-footer sticky bottom-0 z-40 border-t border-white/10 py-3 text-center text-xs text-gray-500">
<div class="max-w-7xl mx-auto px-4 space-y-1.5">
<p>Powered by <a href="https://www.perplexity.ai" target="_blank" rel="noopener" class="text-cf-400 hover:text-cf-300 transition-colors">Perplexity</a></p>
<p class="space-x-3">
<button type="button" class="footer-link" @click="window.dispatchEvent(new CustomEvent('open-policy-modal', { detail: { type: 'terms' } }))">Terms of Use</button>
<button type="button" class="footer-link" @click="window.dispatchEvent(new CustomEvent('open-policy-modal', { detail: { type: 'attribution' } }))">Attribution</button>
<a x-show="githubUrl" :href="githubUrl" target="_blank" rel="noopener" class="footer-link">GitHub</a>
<a x-show="contactEmail" :href="'mailto:' + contactEmail" class="footer-link"
@mouseenter="showHint($event)" @mousemove="moveHint($event)" @mouseleave="hideHint()" x-text="contactEmail"></a>
</p>
<p>&copy; <span x-data x-text="new Date().getFullYear()"></span> ClawFort. All rights reserved.</p>
</div>
<div x-show="hintVisible" x-cloak class="contact-hint" :style="`left:${hintX}px; top:${hintY}px`" x-text="hintText"></div>
</footer>
<div id="cookie-consent-banner" class="hidden fixed bottom-4 left-1/2 -translate-x-1/2 w-[95%] max-w-3xl z-50 rounded-lg border border-white/15 bg-slate-900/95 backdrop-blur p-4 transition-opacity duration-700 opacity-100">
<p class="text-sm text-slate-200 mb-3">
We use analytics cookies to improve the product experience. By choosing "Accept", you consent to analytics tracking.
</p>
<div class="flex justify-end">
<button id="cookie-consent-accept" type="button" class="px-4 py-2 rounded bg-cf-500 hover:bg-cf-600 text-white font-semibold" aria-label="Accept analytics cookies">
Accept
</button>
</div>
</div>
<script>
function extractDomain(url) {
if (!url) return '';
try { return new URL(url).hostname.replace('www.', ''); } catch { return url; }
}
function timeAgo(dateStr) {
if (!dateStr) return '';
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
const intervals = [
[31536000, 'year'], [2592000, 'month'], [86400, 'day'],
[3600, 'hour'], [60, 'minute'], [1, 'second']
];
for (const [secs, label] of intervals) {
const count = Math.floor(seconds / secs);
if (count >= 1) return `${count} ${label}${count > 1 ? 's' : ''} ago`;
}
return 'just now';
}
function preferredImage(item) {
const primary = item?.image_url;
const summary = item?.summary_image_url;
if (primary && primary !== '/static/images/placeholder.png') return primary;
if (summary) return summary;
return '/static/images/placeholder.png';
}
function toAbsoluteUrl(url) {
if (!url) return `${window.location.origin}/`;
try {
return new URL(url, window.location.origin).toString();
} catch {
return `${window.location.origin}/`;
}
}
function articlePermalink(item) {
if (!item?.id) return toAbsoluteUrl('/');
return toAbsoluteUrl(`/?article=${item.id}`);
}
function getPermalinkArticleId() {
const params = new URLSearchParams(window.location.search);
const article = params.get('article');
if (article && /^\d+$/.test(article)) return Number(article);
const match = (window.location.hash || '').match(/^#news-(\d+)$/);
if (match) return Number(match[1]);
return null;
}
function hasPermalinkArticleParam() {
const params = new URLSearchParams(window.location.search);
return params.has('article');
}
function setPermalinkArticleId(articleId) {
if (!articleId) return;
const url = new URL(window.location.href);
url.searchParams.set('article', String(articleId));
url.hash = '';
window.history.replaceState({}, '', url.toString());
}
function clearPermalinkArticleId() {
const url = new URL(window.location.href);
url.searchParams.delete('article');
if ((url.hash || '').startsWith('#news-')) url.hash = '';
window.history.replaceState({}, '', url.toString());
}
function getPolicyModalType() {
const params = new URLSearchParams(window.location.search);
const modal = (params.get('policy') || '').toLowerCase();
if (modal === 'terms' || modal === 'attribution') return modal;
return '';
}
function setPolicyModalType(type) {
if (!type) return;
const url = new URL(window.location.href);
url.searchParams.set('policy', type);
window.history.replaceState({}, '', url.toString());
}
function clearPolicyModalType() {
const url = new URL(window.location.href);
if (url.searchParams.has('policy')) {
url.searchParams.delete('policy');
window.history.replaceState({}, '', url.toString());
}
}
function shareLink(provider, item) {
const permalink = articlePermalink(item);
const encodedUrl = encodeURIComponent(permalink);
const encodedTitle = encodeURIComponent(item?.headline || 'ClawFort AI News');
if (provider === 'x') {
return `https://x.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
}
if (provider === 'whatsapp') {
return `https://wa.me/?text=${encodedTitle}%20${encodedUrl}`;
}
if (provider === 'linkedin') {
return `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`;
}
return permalink;
}
function setMetaContent(selector, content, attribute = 'content') {
if (!content) return;
const node = document.querySelector(selector);
if (node) node.setAttribute(attribute, content);
}
function syncSeoMeta(item) {
const canonical = toAbsoluteUrl(window.location.pathname || '/');
setMetaContent('#canonical-link', canonical, 'href');
setMetaContent('#meta-og-url', canonical);
document.title = 'ClawFort AI News';
if (!item) return;
const title = 'ClawFort AI News';
const description = item.summary || 'Latest AI news updates from ClawFort.';
const image = toAbsoluteUrl(preferredImage(item));
setMetaContent('meta[name="description"]', description);
setMetaContent('meta[property="og:title"]', title);
setMetaContent('meta[property="og:description"]', description);
setMetaContent('#meta-og-image', image);
setMetaContent('meta[name="twitter:title"]', title);
setMetaContent('meta[name="twitter:description"]', description);
setMetaContent('#meta-twitter-image', image);
}
function buildNewsArticleSchema(item) {
if (!item?.headline) return null;
return {
'@type': 'NewsArticle',
headline: item.headline,
description: item.summary || '',
datePublished: item.published_at,
dateModified: item.created_at || item.published_at,
inLanguage: item.language || window._selectedLanguage || 'en',
url: articlePermalink(item),
mainEntityOfPage: articlePermalink(item),
image: [toAbsoluteUrl(preferredImage(item))],
author: { '@type': 'Organization', name: 'ClawFort AI Desk' },
publisher: {
'@type': 'Organization',
name: 'ClawFort',
logo: {
'@type': 'ImageObject',
url: toAbsoluteUrl('/static/images/placeholder.png')
}
},
articleSection: 'AI News',
isBasedOn: item.source_url ? toAbsoluteUrl(item.source_url) : undefined,
};
}
function syncStructuredData() {
const schemaNode = document.getElementById('structured-data-graph');
if (!schemaNode) return;
const heroItem = window.__heroNewsItem || null;
const feedItems = Array.isArray(window.__feedNewsItems) ? window.__feedNewsItems : [];
const articleSchemas = [];
if (heroItem) {
const heroSchema = buildNewsArticleSchema(heroItem);
if (heroSchema) articleSchemas.push(heroSchema);
}
for (const item of feedItems) {
const schema = buildNewsArticleSchema(item);
if (schema) articleSchemas.push(schema);
}
const deduped = [];
const seen = new Set();
for (const schema of articleSchemas) {
const key = schema.url || schema.headline;
if (!seen.has(key)) {
seen.add(key);
deduped.push(schema);
}
}
const payload = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Newspaper',
name: 'ClawFort',
url: toAbsoluteUrl('/'),
inLanguage: window._selectedLanguage || 'en',
description: 'ClawFort AI News - hourly AI news updates with source attribution and concise summaries.',
publisher: {
'@type': 'Organization',
name: 'ClawFort',
logo: {
'@type': 'ImageObject',
url: toAbsoluteUrl('/static/images/placeholder.png')
}
}
},
...deduped,
],
};
schemaNode.textContent = JSON.stringify(payload);
}
function toBulletPoints(text) {
if (!text) return [];
const clean = String(text).replace(/\s+/g, ' ').trim();
if (!clean) return [];
const parts = clean.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean);
if (parts.length > 1) return parts;
const chunks = clean.split(/[,;:]\s+/).map(s => s.trim()).filter(Boolean);
return chunks.length > 1 ? chunks : [clean];
}
function trackEvent(name, data) {
if (window.umami) window.umami.track(name, data);
}
function trackDepth(pct) {
trackEvent('scroll-depth', { depth: pct });
}
function readCookie(name) {
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
return match ? decodeURIComponent(match[2]) : null;
}
function setCookie(name, value, maxAgeSeconds = 31536000) {
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAgeSeconds}; SameSite=Lax`;
}
function safeSetStorage(key, value) {
try {
localStorage.setItem(key, value);
return true;
} catch {
return false;
}
}
function safeGetStorage(key) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function getPreferredTheme() {
const stored = safeGetStorage('clawfort_theme');
if (stored) return stored;
return readCookie('clawfort_theme') || 'system';
}
function getResolvedTheme(themeChoice) {
if (themeChoice === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (themeChoice === 'contrast') return 'contrast';
return themeChoice;
}
function updateThemeButtons(themeChoice) {
document.querySelectorAll('[data-theme-option]').forEach(btn => {
btn.setAttribute('data-active', String(btn.getAttribute('data-theme-option') === themeChoice));
});
}
function applyTheme(themeChoice) {
const resolved = getResolvedTheme(themeChoice);
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.setAttribute('data-theme-choice', themeChoice);
updateThemeButtons(themeChoice);
}
function setThemePreference(themeChoice) {
safeSetStorage('clawfort_theme', themeChoice);
setCookie('clawfort_theme', themeChoice);
applyTheme(themeChoice);
}
function toggleThemeMenu() {
const menu = document.getElementById('theme-menu');
const button = document.getElementById('theme-menu-button');
if (!menu || !button) return;
const isHidden = menu.classList.contains('hidden');
if (isHidden) {
menu.classList.remove('hidden');
button.setAttribute('aria-expanded', 'true');
} else {
menu.classList.add('hidden');
button.setAttribute('aria-expanded', 'false');
}
}
function closeThemeMenu() {
const menu = document.getElementById('theme-menu');
const button = document.getElementById('theme-menu-button');
if (!menu || !button) return;
menu.classList.add('hidden');
button.setAttribute('aria-expanded', 'false');
}
function hasCookieConsent() {
const local = safeGetStorage('clawfort_cookie_consent');
if (local) return local === 'accepted';
return readCookie('clawfort_cookie_consent') === 'accepted';
}
function setCookieConsentAccepted() {
safeSetStorage('clawfort_cookie_consent', 'accepted');
setCookie('clawfort_cookie_consent', 'accepted');
}
function normalizeLanguage(language) {
const supported = ['en', 'ta', 'ml'];
if (supported.includes(language)) return language;
return 'en';
}
function getPreferredLanguage() {
const stored = safeGetStorage('clawfort_language');
if (stored) return normalizeLanguage(stored);
const cookieValue = readCookie('clawfort_language');
if (cookieValue) return normalizeLanguage(cookieValue);
return 'en';
}
function setPreferredLanguage(language) {
const normalized = normalizeLanguage(language);
window._selectedLanguage = normalized;
document.documentElement.setAttribute('data-lang', normalized);
safeSetStorage('clawfort_language', normalized);
setCookie('clawfort_language', normalized);
const select = document.getElementById('language-select');
if (select && select.value !== normalized) {
select.value = normalized;
}
trackEvent('language-change', { language: normalized });
syncStructuredData();
window.dispatchEvent(new CustomEvent('language-changed', { detail: { language: normalized } }));
}
window._selectedLanguage = getPreferredLanguage();
window._themeChoice = getPreferredTheme();
window.__heroNewsItem = null;
window.__feedNewsItems = [];
document.documentElement.setAttribute('data-lang', window._selectedLanguage);
applyTheme(window._themeChoice);
syncSeoMeta(null);
syncStructuredData();
const CONTACT_HINTS = [
'If you have feedback, I would love to hear it.',
'Found a bug? Send a note and I will take a look.',
'Have an idea for ClawFort? Reach out anytime.',
'Suggestions are always welcome here.',
'Want a feature? Let me know by email.',
'Your feedback helps improve this project.',
'Questions, suggestions, or praise - all welcome.',
'Noticed something odd? Please report it.',
'Tell me what would make this site better.',
'Happy to hear your thoughts on UX and content.',
'If something feels off, I am listening.',
'Feature requests are open - send yours.',
'If you care about this project, drop a line.',
'Spotted a typo? I appreciate quick heads-up notes.',
'Your perspective can shape the next update.',
'If this helped you, tell me what to improve next.',
'Ideas are fuel. Share one when you can.',
'Small feedback can create big improvements.',
'If a page confused you, let me know why.',
'Product feedback is always in scope.',
'Have performance concerns? I want to hear them.',
'Your suggestions are part of the roadmap.',
'See something broken? I can fix it faster with details.',
'If a feature is missing, say the word.',
'Tell me how ClawFort could serve you better.',
'Think the UI can be clearer? Share your take.',
'Want better readability options? Send feedback.',
'If translation quality feels off, please report it.',
'Any accessibility concern is worth sharing.',
'If a link fails, I would appreciate the report.',
'Help make this news feed sharper with feedback.',
'Your comments help prioritize updates.',
'Share your favorite improvement idea.',
'If you have a workflow pain point, mention it.',
'Even one sentence of feedback helps.',
'Tell me which feature you use most.',
'If sharing feels clunky, let me know.',
'Want custom views? I am open to suggestions.',
'If something is hard to discover, please say so.',
'I appreciate concrete suggestions and examples.',
'If the mobile layout needs work, send details.',
'A quick email can spark the next fix.',
'Thanks for helping improve this project.',
'If this tool saves you time, tell me what else you need.',
'I welcome constructive feedback.',
'Have a better idea? I am curious.',
'If you found a regression, please report it.',
'Your use case matters - share it.',
'If something should be simpler, I want to know.',
'Feedback is always appreciated. Thank you.',
];
function footerEnhancements() {
return {
githubUrl: '',
contactEmail: '',
hintVisible: false,
hintText: '',
hintX: 16,
hintY: 16,
async init() {
try {
const resp = await fetch('/config');
if (!resp.ok) return;
const cfg = await resp.json();
this.githubUrl = cfg.github_repo_url || '';
this.contactEmail = (cfg.contact_email || '').trim();
} catch {}
},
scrollTop() {
window.scrollTo({ top: 0, behavior: 'smooth' });
},
randomHint() {
return CONTACT_HINTS[Math.floor(Math.random() * CONTACT_HINTS.length)];
},
showHint(event) {
this.hintText = this.randomHint();
this.hintVisible = true;
this.moveHint(event);
},
moveHint(event) {
if (!event) return;
this.hintX = Math.min(event.clientX + 16, window.innerWidth - 290);
this.hintY = Math.min(event.clientY + 18, window.innerHeight - 80);
},
hideHint() {
this.hintVisible = false;
},
};
}
function headerFx() {
return {
scrolled: false,
init() {
const onScroll = () => {
this.scrolled = window.scrollY > 18;
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
},
};
}
function backToTopIsland() {
return {
visible: false,
init() {
const onScroll = () => {
this.visible = window.scrollY > Math.max(380, window.innerHeight * 0.6);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
},
scrollTop() {
window.scrollTo({ top: 0, behavior: 'smooth' });
},
};
}
function policyDisclosures() {
return {
openType: '',
lastActiveElement: null,
init() {
const fromUrl = getPolicyModalType();
if (fromUrl) this.open(fromUrl);
window.addEventListener('open-policy-modal', (event) => {
const modalType = event?.detail?.type;
if (modalType === 'terms' || modalType === 'attribution') {
this.open(modalType);
}
});
},
open(type) {
this.lastActiveElement = document.activeElement;
this.openType = type;
setPolicyModalType(type);
setTimeout(() => {
const closeButton = document.querySelector('[aria-label="Close policy modal"]');
if (closeButton instanceof HTMLElement) closeButton.focus();
}, 0);
},
close() {
this.openType = '';
clearPolicyModalType();
if (this.lastActiveElement instanceof HTMLElement) {
this.lastActiveElement.focus();
}
},
onKeydown(event) {
if (!this.openType) return;
if (event.key === 'Escape') {
this.close();
return;
}
if (event.key !== 'Tab') return;
const focusable = Array.from(document.querySelectorAll('button, a, [tabindex]:not([tabindex="-1"])')).filter(
(el) => el instanceof HTMLElement && !el.hasAttribute('disabled') && el.offsetParent !== null,
);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
},
};
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const choice = getPreferredTheme();
if (choice === 'system') {
applyTheme('system');
}
});
document.addEventListener('click', (event) => {
const root = document.getElementById('theme-menu-root');
if (!root) return;
if (!root.contains(event.target)) {
closeThemeMenu();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeThemeMenu();
}
});
function heroBlock() {
return {
item: null,
loading: true,
heroImageLoading: true,
async init() {
const select = document.getElementById('language-select');
if (select) select.value = window._selectedLanguage;
await this.loadHero();
window.addEventListener('language-changed', () => {
this.loadHero();
});
},
async loadHero() {
this.loading = true;
this.heroImageLoading = true;
this.item = null;
try {
const params = new URLSearchParams({ language: window._selectedLanguage || 'en' });
const resp = await fetch(`/api/news/latest?${params}`);
if (resp.ok) {
const data = await resp.json();
this.item = data || null;
}
} catch (e) { console.error('Hero fetch failed:', e); }
this.loading = false;
window._heroId = this.item?.id;
window.__heroNewsItem = this.item;
syncSeoMeta(this.item);
syncStructuredData();
}
};
}
function newsFeed() {
return {
items: [],
nextCursor: null,
hasMore: true,
initialLoading: true,
loadingMore: false,
observer: null,
modalOpen: false,
modalItem: null,
modalImageLoading: true,
modalTldrLoading: true,
copyStatus: '',
imageLoaded: {},
async init() {
await this.waitForHero();
await this.loadMore();
await this.openFromPermalink();
this.initialLoading = false;
this.setupObserver();
window.addEventListener('language-changed', async () => {
this.items = [];
this.nextCursor = null;
this.hasMore = true;
this.initialLoading = true;
this.imageLoaded = {};
window.__feedNewsItems = [];
await this.waitForHero();
await this.loadMore();
await this.openFromPermalink();
this.initialLoading = false;
});
window.addEventListener('open-summary', (event) => {
if (!event?.detail) return;
this.openSummary(event.detail);
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && this.modalOpen) {
this.closeSummary();
}
});
},
waitForHero() {
return new Promise(resolve => {
const check = () => {
if (window._heroId !== undefined) return resolve();
setTimeout(check, 50);
};
check();
});
},
async loadMore() {
if (this.loadingMore || !this.hasMore) return;
this.loadingMore = true;
try {
const params = new URLSearchParams({
limit: '10',
language: window._selectedLanguage || 'en',
});
if (this.nextCursor) params.set('cursor', this.nextCursor);
if (window._heroId) params.set('exclude_hero', window._heroId);
const resp = await fetch(`/api/news?${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this.items = [...this.items, ...data.items];
for (const item of data.items) {
if (this.imageLoaded[item.id] === undefined) {
this.imageLoaded[item.id] = false;
}
}
this.nextCursor = data.next_cursor;
this.hasMore = data.has_more;
window.__feedNewsItems = this.items;
syncStructuredData();
} catch (e) {
console.error('Feed fetch failed:', e);
this.hasMore = false;
}
this.loadingMore = false;
},
isImageLoading(id) {
return !this.imageLoaded[id];
},
markImageLoaded(id) {
this.imageLoaded[id] = true;
},
handleImageError(id, element) {
element.src = '/static/images/placeholder.png';
this.imageLoaded[id] = true;
},
setupObserver() {
this.observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && this.hasMore && !this.loadingMore) {
this.loadMore();
}
}, { rootMargin: '200px' });
this.observer.observe(this.$refs.sentinel);
},
async openFromPermalink() {
const articleId = getPermalinkArticleId();
if (!articleId) {
if (hasPermalinkArticleParam()) clearPermalinkArticleId();
return;
}
if (window.__heroNewsItem?.id === articleId) {
this.openSummary(window.__heroNewsItem);
return;
}
let item = this.items.find(i => i.id === articleId);
let attempts = 0;
while (!item && this.hasMore && attempts < 5) {
await this.loadMore();
item = this.items.find(i => i.id === articleId);
attempts += 1;
}
if (item) {
this.openSummary(item);
return;
}
clearPermalinkArticleId();
},
openSummary(item) {
const normalized = {
...(item || {}),
summary_image_url: item?.summary_image_url || item?.image_url || '/static/images/placeholder.png',
tldr_points: Array.isArray(item?.tldr_points)
? item.tldr_points
: toBulletPoints(item?.summary_body || item?.summary || ''),
};
this.modalItem = normalized;
this.modalOpen = true;
this.modalImageLoading = true;
this.modalTldrLoading = true;
this.copyStatus = '';
setPermalinkArticleId(item?.id);
setTimeout(() => {
if (this.modalOpen) this.modalTldrLoading = false;
}, 250);
trackEvent('summary-modal-open', {
article_id: item.id,
article_title: item.headline || null,
});
},
closeSummary() {
const id = this.modalItem ? this.modalItem.id : null;
const title = this.modalItem ? this.modalItem.headline : null;
this.modalOpen = false;
this.modalItem = null;
this.modalTldrLoading = true;
this.copyStatus = '';
clearPermalinkArticleId();
trackEvent('summary-modal-close', {
article_id: id,
article_title: title,
});
},
trackSummarySource(item) {
if (!item) return;
trackEvent('summary-modal-link-out', {
article_id: item.id,
source_url: item.source_url,
});
},
async copyShareLink(item) {
if (!item?.id) return;
const permalink = articlePermalink(item);
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(permalink);
} else {
const temp = document.createElement('textarea');
temp.value = permalink;
temp.setAttribute('readonly', '');
temp.style.position = 'absolute';
temp.style.left = '-9999px';
document.body.appendChild(temp);
temp.select();
document.execCommand('copy');
document.body.removeChild(temp);
}
this.copyStatus = 'copied';
trackEvent('summary-modal-copy-link', {
article_id: item.id,
article_title: item.headline || null,
});
setTimeout(() => {
if (this.copyStatus === 'copied') this.copyStatus = '';
}, 1400);
} catch {
this.copyStatus = '';
}
}
};
}
(async function initAnalytics() {
async function loadUmami() {
if (window.__umamiLoaded) return;
const resp = await fetch('/config');
if (!resp.ok) return;
const cfg = await resp.json();
if (!cfg.umami_script_url || !cfg.umami_website_id) return;
const s = document.createElement('script');
s.defer = true;
s.src = cfg.umami_script_url;
s.setAttribute('data-website-id', cfg.umami_website_id);
document.head.appendChild(s);
window.__umamiLoaded = true;
}
try {
const banner = document.getElementById('cookie-consent-banner');
const accept = document.getElementById('cookie-consent-accept');
function hideBannerWithFade() {
if (!banner || banner.classList.contains('hidden')) return;
banner.classList.add('opacity-0');
setTimeout(() => {
banner.classList.add('hidden');
banner.classList.remove('opacity-0');
}, 700);
}
if (hasCookieConsent()) {
await loadUmami();
} else if (banner) {
banner.classList.remove('hidden');
setTimeout(() => {
hideBannerWithFade();
}, 10000);
}
if (accept) {
accept.addEventListener('click', async () => {
setCookieConsentAccepted();
hideBannerWithFade();
await loadUmami();
trackEvent('cookie-consent-accepted');
});
}
} catch {}
})();
</script>
</body>
</html>