Initial Commit
This commit is contained in:
364
frontend/index.html
Normal file
364
frontend/index.html
Normal file
@@ -0,0 +1,364 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ClawFort — AI News</title>
|
||||
<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&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', 'system-ui', 'sans-serif'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
[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; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-cf-950 text-gray-100 font-sans min-h-screen">
|
||||
|
||||
<header class="sticky top-0 z-50 bg-cf-950/80 backdrop-blur-lg border-b border-white/5">
|
||||
<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="bg-[#1e293b] border border-white/10 text-gray-200 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>
|
||||
<span class="text-xs text-gray-500 hidden lg:block">AI News — Updated Hourly</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<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">
|
||||
<div class="fade-up">
|
||||
<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">
|
||||
<img :src="item.image_url" :alt="item.headline"
|
||||
class="w-full h-[300px] sm:h-[400px] lg:h-[480px] object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
@error="$el.src='/static/images/placeholder.png'">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-cf-950 via-cf-950/40 to-transparent"></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 bg-cf-500/20 text-cf-400 text-xs font-semibold rounded-full border border-cf-500/30">LATEST</span>
|
||||
<span class="text-gray-400 text-sm" 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" x-text="item.headline"></h1>
|
||||
<p class="text-gray-300 text-base sm:text-lg max-w-3xl line-clamp-3 mb-4" x-text="item.summary"></p>
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-400">
|
||||
<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>
|
||||
</div>
|
||||
</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="bg-[#1e293b] 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 cursor-pointer"
|
||||
@click="window.open(item.source_url || '#', '_blank'); trackEvent('card-click')">
|
||||
<div class="relative h-48 overflow-hidden">
|
||||
<img :src="item.image_url" :alt="item.headline" loading="lazy"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
@error="$el.src='/static/images/placeholder.png'">
|
||||
<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" x-text="item.headline"></h3>
|
||||
<p class="text-gray-400 text-sm line-clamp-2 mb-3" x-text="item.summary"></p>
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<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>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</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 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>© <span x-data x-text="new Date().getFullYear()"></span> ClawFort. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<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 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 normalizeLanguage(language) {
|
||||
const supported = ['en', 'ta', 'ml'];
|
||||
if (supported.includes(language)) return language;
|
||||
return 'en';
|
||||
}
|
||||
|
||||
function getPreferredLanguage() {
|
||||
const stored = localStorage.getItem('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;
|
||||
localStorage.setItem('clawfort_language', normalized);
|
||||
document.cookie = `clawfort_language=${encodeURIComponent(normalized)}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
const select = document.getElementById('language-select');
|
||||
if (select && select.value !== normalized) {
|
||||
select.value = normalized;
|
||||
}
|
||||
trackEvent('language-change', { language: normalized });
|
||||
window.dispatchEvent(new CustomEvent('language-changed', { detail: { language: normalized } }));
|
||||
}
|
||||
|
||||
window._selectedLanguage = getPreferredLanguage();
|
||||
|
||||
function heroBlock() {
|
||||
return {
|
||||
item: null,
|
||||
loading: 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;
|
||||
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();
|
||||
if (data) this.item = data;
|
||||
}
|
||||
} catch (e) { console.error('Hero fetch failed:', e); }
|
||||
this.loading = false;
|
||||
window._heroId = this.item?.id;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function newsFeed() {
|
||||
return {
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasMore: true,
|
||||
initialLoading: true,
|
||||
loadingMore: false,
|
||||
observer: null,
|
||||
|
||||
async init() {
|
||||
await this.waitForHero();
|
||||
await this.loadMore();
|
||||
this.initialLoading = false;
|
||||
this.setupObserver();
|
||||
window.addEventListener('language-changed', async () => {
|
||||
this.items = [];
|
||||
this.nextCursor = null;
|
||||
this.hasMore = true;
|
||||
this.initialLoading = true;
|
||||
await this.waitForHero();
|
||||
await this.loadMore();
|
||||
this.initialLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
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];
|
||||
this.nextCursor = data.next_cursor;
|
||||
this.hasMore = data.has_more;
|
||||
} catch (e) {
|
||||
console.error('Feed fetch failed:', e);
|
||||
this.hasMore = false;
|
||||
}
|
||||
this.loadingMore = false;
|
||||
},
|
||||
|
||||
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 function initAnalytics() {
|
||||
try {
|
||||
const resp = await fetch('/config');
|
||||
if (!resp.ok) return;
|
||||
const cfg = await resp.json();
|
||||
if (cfg.umami_script_url && cfg.umami_website_id) {
|
||||
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);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user