1235 lines
54 KiB
HTML
1235 lines
54 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); }
|
|
.cf-header { background: var(--cf-header-bg); }
|
|
.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.94), rgba(15, 23, 42, 0.62), rgba(15, 23, 42, 0.22), 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;
|
|
}
|
|
.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); }
|
|
.footer-link { text-decoration: underline; text-underline-offset: 2px; }
|
|
.footer-link:hover { color: #dbeafe; }
|
|
.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; }
|
|
}
|
|
</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 class="sticky top-0 z-50 backdrop-blur-lg border-b border-white/5 cf-header">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
|
<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()">☼</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">🖥 <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">☀ <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">☾ <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">◑ <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>
|
|
<a :href="articlePermalink(item)" class="hover:text-cf-300 transition-colors underline underline-offset-2">
|
|
Permalink
|
|
</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>
|
|
<div class="flex items-center gap-2">
|
|
<a :href="articlePermalink(item)" class="hover:text-cf-300 underline underline-offset-2">Link</a>
|
|
<span x-text="timeAgo(item.published_at)"></span>
|
|
</div>
|
|
</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>
|
|
</div>
|
|
</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>
|
|
|
|
<footer x-data="footerEnhancements()" x-init="init()" class="border-t border-white/5 py-8 text-center text-sm text-gray-500">
|
|
<div class="max-w-7xl mx-auto px-4 space-y-2">
|
|
<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">
|
|
<a href="/terms" class="footer-link">Terms of Use</a>
|
|
<a href="/attribution" class="footer-link">Attribution</a>
|
|
<button type="button" class="footer-link" @click="scrollTop()">Back to Top</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()">Email me</a>
|
|
</p>
|
|
<p>© <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 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 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 || '';
|
|
} 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;
|
|
},
|
|
};
|
|
}
|
|
|
|
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,
|
|
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);
|
|
});
|
|
},
|
|
|
|
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) 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);
|
|
},
|
|
|
|
openSummary(item) {
|
|
this.modalItem = item;
|
|
this.modalOpen = true;
|
|
this.modalImageLoading = true;
|
|
this.modalTldrLoading = true;
|
|
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;
|
|
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 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>
|