update
This commit is contained in:
315
frontend/public/admin.js
Normal file
315
frontend/public/admin.js
Normal file
@@ -0,0 +1,315 @@
|
||||
// Admin Dashboard JavaScript
|
||||
// Use relative URL to go through frontend proxy
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
// Load all data on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSystemStats();
|
||||
loadOllamaStatus();
|
||||
loadGPUStatus();
|
||||
loadPerformanceTest();
|
||||
loadModels();
|
||||
loadConfig();
|
||||
loadClusteringStats();
|
||||
});
|
||||
|
||||
// Refresh all data
|
||||
function refreshAll() {
|
||||
loadSystemStats();
|
||||
loadOllamaStatus();
|
||||
loadGPUStatus();
|
||||
loadPerformanceTest();
|
||||
loadModels();
|
||||
loadConfig();
|
||||
loadClusteringStats();
|
||||
}
|
||||
|
||||
// Load system statistics
|
||||
async function loadSystemStats() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/stats`);
|
||||
const data = await response.json();
|
||||
|
||||
const html = `
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Total Articles</span>
|
||||
<span class="stat-value">${data.articles || 0}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Crawled Articles</span>
|
||||
<span class="stat-value">${data.crawled_articles || 0}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">AI Summarized</span>
|
||||
<span class="stat-value">${data.summarized_articles || 0}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Clustered Articles</span>
|
||||
<span class="stat-value">${data.clustered_articles || 0}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Neutral Summaries</span>
|
||||
<span class="stat-value">${data.neutral_summaries || 0}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Active Subscribers</span>
|
||||
<span class="stat-value">${data.subscribers || 0}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('systemStats').innerHTML = html;
|
||||
} catch (error) {
|
||||
document.getElementById('systemStats').innerHTML = `<div class="error">Error loading stats: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load Ollama status
|
||||
async function loadOllamaStatus() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/ollama/ping`);
|
||||
const data = await response.json();
|
||||
|
||||
const isActive = data.status === 'success';
|
||||
const statusClass = isActive ? 'status-active' : 'status-inactive';
|
||||
|
||||
const html = `
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Status</span>
|
||||
<span class="stat-value">
|
||||
<span class="status-indicator ${statusClass}"></span>
|
||||
${isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Base URL</span>
|
||||
<span class="stat-value">${data.ollama_config?.base_url || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Current Model</span>
|
||||
<span class="stat-value">${data.ollama_config?.model || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Enabled</span>
|
||||
<span class="stat-value">${data.ollama_config?.enabled ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
${isActive ? `
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Response</span>
|
||||
<span class="stat-value" style="font-size: 12px;">${data.response?.substring(0, 50)}...</span>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
document.getElementById('ollamaStatus').innerHTML = html;
|
||||
} catch (error) {
|
||||
document.getElementById('ollamaStatus').innerHTML = `<div class="error">Error: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load GPU status
|
||||
async function loadGPUStatus() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/ollama/gpu-status`);
|
||||
const data = await response.json();
|
||||
|
||||
const gpuActive = data.gpu_in_use;
|
||||
const statusClass = gpuActive ? 'status-active' : 'status-warning';
|
||||
|
||||
const html = `
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">GPU Available</span>
|
||||
<span class="stat-value">
|
||||
<span class="status-indicator ${data.gpu_available ? 'status-active' : 'status-inactive'}"></span>
|
||||
${data.gpu_available ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">GPU In Use</span>
|
||||
<span class="stat-value">
|
||||
<span class="status-indicator ${statusClass}"></span>
|
||||
${gpuActive ? 'Yes' : 'No (CPU Mode)'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Models Loaded</span>
|
||||
<span class="stat-value">${data.models_loaded || 0}</span>
|
||||
</div>
|
||||
${data.gpu_details ? `
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">GPU Model</span>
|
||||
<span class="stat-value">${data.gpu_details.model}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">GPU Layers</span>
|
||||
<span class="stat-value">${data.gpu_details.gpu_layers}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${!gpuActive ? `
|
||||
<div style="margin-top: 10px; padding: 10px; background: #fef3c7; border-radius: 5px; font-size: 12px;">
|
||||
💡 Enable GPU for 5-10x faster processing
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
document.getElementById('gpuStatus').innerHTML = html;
|
||||
} catch (error) {
|
||||
document.getElementById('gpuStatus').innerHTML = `<div class="error">Error: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load performance test
|
||||
async function loadPerformanceTest() {
|
||||
document.getElementById('performanceTest').innerHTML = '<div class="loading">Click "Run Test" to check performance</div>';
|
||||
}
|
||||
|
||||
// Run performance test
|
||||
async function runPerformanceTest() {
|
||||
document.getElementById('performanceTest').innerHTML = '<div class="loading">Running test...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/ollama/test`);
|
||||
const data = await response.json();
|
||||
|
||||
let badgeClass = 'badge-fair';
|
||||
if (data.duration_seconds < 5) badgeClass = 'badge-excellent';
|
||||
else if (data.duration_seconds < 15) badgeClass = 'badge-good';
|
||||
else if (data.duration_seconds > 30) badgeClass = 'badge-slow';
|
||||
|
||||
const html = `
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Duration</span>
|
||||
<span class="stat-value">${data.duration_seconds}s</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Performance</span>
|
||||
<span class="stat-value">
|
||||
<span class="performance-badge ${badgeClass}">${data.performance}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Model</span>
|
||||
<span class="stat-value">${data.model}</span>
|
||||
</div>
|
||||
<div style="margin-top: 10px; padding: 10px; background: #f3f4f6; border-radius: 5px; font-size: 12px;">
|
||||
${data.recommendation}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('performanceTest').innerHTML = html;
|
||||
} catch (error) {
|
||||
document.getElementById('performanceTest').innerHTML = `<div class="error">Error: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load available models
|
||||
async function loadModels() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/ollama/models`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.models && data.models.length > 0) {
|
||||
const modelsList = data.models.map(model => {
|
||||
const isCurrent = model === data.current_model;
|
||||
return `<li class="${isCurrent ? 'current-model' : ''}">${model} ${isCurrent ? '(current)' : ''}</li>`;
|
||||
}).join('');
|
||||
|
||||
const html = `
|
||||
<ul class="model-list">
|
||||
${modelsList}
|
||||
</ul>
|
||||
<div style="margin-top: 10px; font-size: 12px; color: #666;">
|
||||
Current: ${data.current_model}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('modelsList').innerHTML = html;
|
||||
} else {
|
||||
document.getElementById('modelsList').innerHTML = '<div>No models found</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('modelsList').innerHTML = `<div class="error">Error: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/ollama/config`);
|
||||
const data = await response.json();
|
||||
|
||||
const html = `
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Base URL</span>
|
||||
<span class="stat-value" style="font-size: 12px;">${data.ollama_config?.base_url || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Model</span>
|
||||
<span class="stat-value">${data.ollama_config?.model || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Enabled</span>
|
||||
<span class="stat-value">${data.ollama_config?.enabled ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Has API Key</span>
|
||||
<span class="stat-value">${data.ollama_config?.has_api_key ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
<div style="margin-top: 10px; padding: 10px; background: #f3f4f6; border-radius: 5px; font-size: 11px;">
|
||||
<strong>Config file:</strong> ${data.env_file_path || 'N/A'}<br>
|
||||
<strong>Exists:</strong> ${data.env_file_exists ? 'Yes' : 'No'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('configInfo').innerHTML = html;
|
||||
} catch (error) {
|
||||
document.getElementById('configInfo').innerHTML = `<div class="error">Error: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load clustering statistics
|
||||
async function loadClusteringStats() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/stats`);
|
||||
const data = await response.json();
|
||||
|
||||
const clusteringRate = data.clustered_articles > 0
|
||||
? ((data.neutral_summaries / data.clustered_articles) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
const html = `
|
||||
<div class="dashboard-grid">
|
||||
<div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Total Articles</span>
|
||||
<span class="stat-value">${data.articles || 0}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Clustered Articles</span>
|
||||
<span class="stat-value">${data.clustered_articles || 0}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Neutral Summaries</span>
|
||||
<span class="stat-value">${data.neutral_summaries || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Clustering Rate</span>
|
||||
<span class="stat-value">${clusteringRate}%</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Multi-Source Stories</span>
|
||||
<span class="stat-value">${data.neutral_summaries || 0}</span>
|
||||
</div>
|
||||
<div style="margin-top: 10px; padding: 10px; background: #dbeafe; border-radius: 5px; font-size: 12px;">
|
||||
<strong>AI Clustering:</strong> Automatically detects duplicate stories from different sources and generates neutral summaries.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('clusteringStats').innerHTML = html;
|
||||
} catch (error) {
|
||||
document.getElementById('clusteringStats').innerHTML = `<div class="error">Error: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,268 @@
|
||||
// Pagination state
|
||||
let allArticles = [];
|
||||
let filteredArticles = [];
|
||||
let displayedCount = 0;
|
||||
const ARTICLES_PER_PAGE = 5;
|
||||
let isLoading = false;
|
||||
let searchQuery = '';
|
||||
|
||||
// Load news on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadNews();
|
||||
loadStats();
|
||||
setupInfiniteScroll();
|
||||
});
|
||||
|
||||
async function loadNews() {
|
||||
const newsGrid = document.getElementById('newsGrid');
|
||||
newsGrid.innerHTML = '<div class="loading">Loading news...</div>';
|
||||
newsGrid.innerHTML = '<div class="text-center py-10 text-gray-500">Loading news...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/news');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.articles && data.articles.length > 0) {
|
||||
displayNews(data.articles);
|
||||
allArticles = data.articles;
|
||||
filteredArticles = data.articles;
|
||||
displayedCount = 0;
|
||||
newsGrid.innerHTML = '';
|
||||
updateSearchStats();
|
||||
loadMoreArticles();
|
||||
} else {
|
||||
newsGrid.innerHTML = '<div class="loading">No news available at the moment. Check back later!</div>';
|
||||
newsGrid.innerHTML = '<div class="text-center py-10 text-gray-500">No news available at the moment. Check back later!</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading news:', error);
|
||||
newsGrid.innerHTML = '<div class="loading">Failed to load news. Please try again later.</div>';
|
||||
newsGrid.innerHTML = '<div class="text-center py-10 text-gray-500">Failed to load news. Please try again later.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function displayNews(articles) {
|
||||
function loadMoreArticles() {
|
||||
if (isLoading || displayedCount >= filteredArticles.length) return;
|
||||
|
||||
isLoading = true;
|
||||
const newsGrid = document.getElementById('newsGrid');
|
||||
|
||||
// Remove loading indicator if exists
|
||||
const loadingIndicator = document.getElementById('loadingIndicator');
|
||||
if (loadingIndicator) loadingIndicator.remove();
|
||||
|
||||
// Get next batch of articles
|
||||
const nextBatch = filteredArticles.slice(displayedCount, displayedCount + ARTICLES_PER_PAGE);
|
||||
|
||||
nextBatch.forEach((article, index) => {
|
||||
const card = createNewsCard(article, displayedCount + index);
|
||||
newsGrid.appendChild(card);
|
||||
});
|
||||
|
||||
displayedCount += nextBatch.length;
|
||||
|
||||
// Add loading indicator if more articles available
|
||||
if (displayedCount < filteredArticles.length) {
|
||||
const loader = document.createElement('div');
|
||||
loader.id = 'loadingIndicator';
|
||||
loader.className = 'text-center py-8 text-gray-400';
|
||||
loader.innerHTML = '<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div><p class="mt-2">Loading more...</p>';
|
||||
newsGrid.appendChild(loader);
|
||||
} else if (filteredArticles.length > 0) {
|
||||
// Add end message
|
||||
const endMessage = document.createElement('div');
|
||||
endMessage.className = 'text-center py-8 text-gray-400 text-sm';
|
||||
endMessage.textContent = `✓ All ${filteredArticles.length} articles loaded`;
|
||||
newsGrid.appendChild(endMessage);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
function setupInfiniteScroll() {
|
||||
window.addEventListener('scroll', () => {
|
||||
if (isLoading || displayedCount >= filteredArticles.length) return;
|
||||
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const threshold = document.documentElement.scrollHeight - 500;
|
||||
|
||||
if (scrollPosition >= threshold) {
|
||||
loadMoreArticles();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
function handleSearch() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const clearBtn = document.getElementById('clearSearch');
|
||||
searchQuery = searchInput.value.trim().toLowerCase();
|
||||
|
||||
// Show/hide clear button
|
||||
if (searchQuery) {
|
||||
clearBtn.classList.remove('hidden');
|
||||
} else {
|
||||
clearBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Filter articles
|
||||
if (searchQuery === '') {
|
||||
filteredArticles = allArticles;
|
||||
} else {
|
||||
filteredArticles = allArticles.filter(article => {
|
||||
const title = article.title.toLowerCase();
|
||||
const summary = (article.summary || '').toLowerCase().replace(/<[^>]*>/g, '');
|
||||
const source = formatSourceName(article.source).toLowerCase();
|
||||
|
||||
return title.includes(searchQuery) ||
|
||||
summary.includes(searchQuery) ||
|
||||
source.includes(searchQuery);
|
||||
});
|
||||
}
|
||||
|
||||
// Reset display
|
||||
displayedCount = 0;
|
||||
const newsGrid = document.getElementById('newsGrid');
|
||||
newsGrid.innerHTML = '';
|
||||
|
||||
articles.forEach(article => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'news-card';
|
||||
card.onclick = () => window.open(article.link, '_blank');
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="source">${article.source || 'Munich News'}</div>
|
||||
<h3>${article.title}</h3>
|
||||
<p>${article.summary || 'No summary available.'}</p>
|
||||
<a href="${article.link}" target="_blank" class="read-more" onclick="event.stopPropagation()">Read more →</a>
|
||||
// Update stats
|
||||
updateSearchStats();
|
||||
|
||||
// Load filtered articles
|
||||
if (filteredArticles.length > 0) {
|
||||
loadMoreArticles();
|
||||
} else {
|
||||
newsGrid.innerHTML = `
|
||||
<div class="text-center py-16">
|
||||
<div class="text-6xl mb-4">🔍</div>
|
||||
<p class="text-xl text-gray-600 mb-2">No articles found</p>
|
||||
<p class="text-gray-400">Try a different search term</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
newsGrid.appendChild(card);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
searchInput.value = '';
|
||||
handleSearch();
|
||||
searchInput.focus();
|
||||
}
|
||||
|
||||
function updateSearchStats() {
|
||||
const searchStats = document.getElementById('searchStats');
|
||||
if (searchQuery) {
|
||||
searchStats.textContent = `Found ${filteredArticles.length} of ${allArticles.length} articles`;
|
||||
} else {
|
||||
searchStats.textContent = `Showing ${allArticles.length} articles`;
|
||||
}
|
||||
}
|
||||
|
||||
function createNewsCard(article, index) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'group bg-white rounded-xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 cursor-pointer border border-gray-100 hover:border-primary/30';
|
||||
card.onclick = () => window.open(article.link, '_blank');
|
||||
|
||||
// Extract image from summary if it's an img tag (from Süddeutsche)
|
||||
let imageUrl = null;
|
||||
let cleanSummary = article.summary || 'No summary available.';
|
||||
|
||||
if (cleanSummary.includes('<img')) {
|
||||
const imgMatch = cleanSummary.match(/src="([^"]+)"/);
|
||||
if (imgMatch) {
|
||||
imageUrl = imgMatch[1];
|
||||
}
|
||||
// Remove img tag from summary
|
||||
cleanSummary = cleanSummary.replace(/<img[^>]*>/g, '').replace(/<\/?p>/g, '').trim();
|
||||
}
|
||||
|
||||
// Get source icon/emoji
|
||||
const sourceIcon = getSourceIcon(article.source);
|
||||
|
||||
// Format source name
|
||||
const sourceName = formatSourceName(article.source);
|
||||
|
||||
// Get word count badge
|
||||
const wordCount = article.word_count || article.summary_word_count;
|
||||
const readTime = wordCount ? Math.ceil(wordCount / 200) : null;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<!-- Image -->
|
||||
<div class="relative w-full sm:w-64 h-48 sm:h-auto bg-gradient-to-br from-primary to-secondary flex items-center justify-center text-white text-5xl overflow-hidden flex-shrink-0">
|
||||
${imageUrl ? `<img src="${imageUrl}" alt="${article.title}" class="w-full h-full object-cover" onerror="this.style.display='none'; this.parentElement.innerHTML='${sourceIcon}';">` : sourceIcon}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 flex flex-col flex-1">
|
||||
<!-- Source Badge -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="w-1.5 h-1.5 bg-primary rounded-full"></span>
|
||||
<span class="text-xs font-bold text-primary uppercase tracking-wide">${sourceName}</span>
|
||||
${readTime ? `<span class="text-xs text-gray-400 ml-auto">📖 ${readTime} min read</span>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-3 leading-tight group-hover:text-primary transition-colors">${article.title}</h3>
|
||||
|
||||
<!-- Summary -->
|
||||
<p class="text-sm text-gray-600 mb-4 leading-relaxed line-clamp-2">${cleanSummary}</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center gap-4 mt-auto">
|
||||
<a href="${article.link}" target="_blank" onclick="event.stopPropagation()" class="text-primary font-semibold text-sm hover:gap-2 flex items-center gap-1 transition-all">
|
||||
Read full article <span class="group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add staggered animation
|
||||
card.style.opacity = '0';
|
||||
card.style.animation = `fadeIn 0.5s ease-out ${(index % ARTICLES_PER_PAGE) * 0.1}s forwards`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Add animation keyframes
|
||||
if (!document.getElementById('animations')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'animations';
|
||||
style.textContent = `
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.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;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function getSourceIcon(source) {
|
||||
const icons = {
|
||||
'abendzeitung-muenchen': '📰',
|
||||
'sueddeutsche': '📄',
|
||||
'muenchen': '🏛️',
|
||||
'default': '📰'
|
||||
};
|
||||
return icons[source] || icons.default;
|
||||
}
|
||||
|
||||
function formatSourceName(source) {
|
||||
const names = {
|
||||
'abendzeitung-muenchen': 'Abendzeitung München',
|
||||
'sueddeutsche': 'Süddeutsche Zeitung',
|
||||
'muenchen': 'München.de'
|
||||
};
|
||||
return names[source] || source.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
@@ -65,12 +287,13 @@ async function subscribe() {
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
formMessage.textContent = 'Please enter a valid email address';
|
||||
formMessage.className = 'form-message error';
|
||||
formMessage.className = 'text-red-200 font-medium';
|
||||
return;
|
||||
}
|
||||
|
||||
subscribeBtn.disabled = true;
|
||||
subscribeBtn.textContent = 'Subscribing...';
|
||||
subscribeBtn.classList.add('opacity-75', 'cursor-not-allowed');
|
||||
formMessage.textContent = '';
|
||||
|
||||
try {
|
||||
@@ -86,19 +309,20 @@ async function subscribe() {
|
||||
|
||||
if (response.ok) {
|
||||
formMessage.textContent = data.message || 'Successfully subscribed! Check your email for confirmation.';
|
||||
formMessage.className = 'form-message success';
|
||||
formMessage.className = 'text-green-200 font-medium';
|
||||
emailInput.value = '';
|
||||
loadStats(); // Refresh stats
|
||||
} else {
|
||||
formMessage.textContent = data.error || 'Failed to subscribe. Please try again.';
|
||||
formMessage.className = 'form-message error';
|
||||
formMessage.className = 'text-red-200 font-medium';
|
||||
}
|
||||
} catch (error) {
|
||||
formMessage.textContent = 'Network error. Please try again later.';
|
||||
formMessage.className = 'form-message error';
|
||||
formMessage.className = 'text-red-200 font-medium';
|
||||
} finally {
|
||||
subscribeBtn.disabled = false;
|
||||
subscribeBtn.textContent = 'Subscribe Free';
|
||||
subscribeBtn.classList.remove('opacity-75', 'cursor-not-allowed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,13 +334,14 @@ document.getElementById('emailInput').addEventListener('keypress', (e) => {
|
||||
});
|
||||
|
||||
function showUnsubscribe() {
|
||||
document.getElementById('unsubscribeModal').style.display = 'block';
|
||||
document.getElementById('unsubscribeModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeUnsubscribe() {
|
||||
document.getElementById('unsubscribeModal').style.display = 'none';
|
||||
document.getElementById('unsubscribeModal').classList.add('hidden');
|
||||
document.getElementById('unsubscribeEmail').value = '';
|
||||
document.getElementById('unsubscribeMessage').textContent = '';
|
||||
document.getElementById('unsubscribeMessage').className = '';
|
||||
}
|
||||
|
||||
async function unsubscribe() {
|
||||
@@ -127,7 +352,7 @@ async function unsubscribe() {
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
unsubscribeMessage.textContent = 'Please enter a valid email address';
|
||||
unsubscribeMessage.className = 'form-message error';
|
||||
unsubscribeMessage.className = 'text-red-600 font-medium';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,7 +369,7 @@ async function unsubscribe() {
|
||||
|
||||
if (response.ok) {
|
||||
unsubscribeMessage.textContent = data.message || 'Successfully unsubscribed.';
|
||||
unsubscribeMessage.className = 'form-message success';
|
||||
unsubscribeMessage.className = 'text-green-600 font-medium';
|
||||
emailInput.value = '';
|
||||
setTimeout(() => {
|
||||
closeUnsubscribe();
|
||||
@@ -152,11 +377,11 @@ async function unsubscribe() {
|
||||
}, 2000);
|
||||
} else {
|
||||
unsubscribeMessage.textContent = data.error || 'Failed to unsubscribe. Please try again.';
|
||||
unsubscribeMessage.className = 'form-message error';
|
||||
unsubscribeMessage.className = 'text-red-600 font-medium';
|
||||
}
|
||||
} catch (error) {
|
||||
unsubscribeMessage.textContent = 'Network error. Please try again later.';
|
||||
unsubscribeMessage.className = 'form-message error';
|
||||
unsubscribeMessage.className = 'text-red-600 font-medium';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,58 +4,123 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Munich News Daily - Your Daily Dose of Munich News</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#667eea',
|
||||
secondary: '#764ba2',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="hero">
|
||||
<div class="hero-content">
|
||||
<h1>📰 Munich News Daily</h1>
|
||||
<p class="tagline">Get the latest Munich news delivered to your inbox every morning</p>
|
||||
<p class="description">Stay informed about what's happening in Munich with our curated daily newsletter. No fluff, just the news that matters.</p>
|
||||
<body class="bg-gradient-to-br from-primary to-secondary min-h-screen">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero Section -->
|
||||
<header class="text-center text-white py-16 sm:py-20">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<h1 class="text-5xl sm:text-6xl font-extrabold mb-6 tracking-tight">📰 Munich News Daily</h1>
|
||||
<p class="text-2xl sm:text-3xl font-light mb-4">Get the latest Munich news delivered to your inbox every morning</p>
|
||||
<p class="text-lg sm:text-xl opacity-95 mb-10">Stay informed about what's happening in Munich with our curated daily newsletter. No fluff, just the news that matters.</p>
|
||||
|
||||
<div class="subscription-form" id="subscriptionForm">
|
||||
<!-- Subscription Form -->
|
||||
<div class="max-w-md mx-auto space-y-4 mb-10">
|
||||
<input
|
||||
type="email"
|
||||
id="emailInput"
|
||||
placeholder="Enter your email address"
|
||||
class="w-full px-6 py-4 rounded-lg text-gray-800 text-lg focus:outline-none focus:ring-4 focus:ring-white/30 shadow-lg"
|
||||
required
|
||||
>
|
||||
<button id="subscribeBtn" onclick="subscribe()">Subscribe Free</button>
|
||||
<p class="form-message" id="formMessage"></p>
|
||||
<button
|
||||
id="subscribeBtn"
|
||||
onclick="subscribe()"
|
||||
class="w-full px-8 py-4 bg-red-500 hover:bg-red-600 text-white font-semibold text-lg rounded-lg shadow-lg transform transition hover:scale-105 active:scale-95"
|
||||
>
|
||||
Subscribe Free
|
||||
</button>
|
||||
<p id="formMessage" class="text-sm min-h-[20px]"></p>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-number" id="subscriberCount">-</span>
|
||||
<span class="stat-label">Subscribers</span>
|
||||
<!-- Stats -->
|
||||
<div class="flex justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold mb-1" id="subscriberCount">-</div>
|
||||
<div class="text-sm opacity-80">Subscribers</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="news-section">
|
||||
<h2>Latest Munich News</h2>
|
||||
<div class="news-grid" id="newsGrid">
|
||||
<div class="loading">Loading news...</div>
|
||||
<!-- News Section -->
|
||||
<section class="bg-white rounded-2xl shadow-2xl p-6 sm:p-10 mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-800 mb-8 text-center">Latest Munich News</h2>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="max-w-2xl mx-auto mb-8">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="searchInput"
|
||||
placeholder="🔍 Search articles by title, content, or source..."
|
||||
class="w-full px-6 py-4 pr-12 rounded-lg border-2 border-gray-200 focus:border-primary focus:outline-none text-gray-800 text-lg transition-all"
|
||||
oninput="handleSearch()"
|
||||
>
|
||||
<button
|
||||
id="clearSearch"
|
||||
onclick="clearSearch()"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-2xl hidden"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div id="searchStats" class="text-sm text-gray-500 mt-2 text-center"></div>
|
||||
</div>
|
||||
|
||||
<div id="newsGrid" class="space-y-6">
|
||||
<div class="text-center py-10 text-gray-500">Loading news...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>© 2024 Munich News Daily. Made with ❤️ for Munich.</p>
|
||||
<p><a href="#" onclick="showUnsubscribe()">Unsubscribe</a></p>
|
||||
<!-- Footer -->
|
||||
<footer class="text-center text-white py-8">
|
||||
<p class="mb-2">© 2024 Munich News Daily. Made with ❤️ for Munich.</p>
|
||||
<p class="space-x-2">
|
||||
<a href="#" onclick="showUnsubscribe()" class="underline hover:opacity-80">Unsubscribe</a>
|
||||
<span>|</span>
|
||||
<a href="/admin.html" class="underline hover:opacity-80">Admin Dashboard</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Unsubscribe Modal -->
|
||||
<div class="modal" id="unsubscribeModal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeUnsubscribe()">×</span>
|
||||
<h2>Unsubscribe</h2>
|
||||
<p>Enter your email to unsubscribe from Munich News Daily:</p>
|
||||
<input type="email" id="unsubscribeEmail" placeholder="Enter your email">
|
||||
<button onclick="unsubscribe()">Unsubscribe</button>
|
||||
<p class="form-message" id="unsubscribeMessage"></p>
|
||||
<div id="unsubscribeModal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-8 relative">
|
||||
<button onclick="closeUnsubscribe()" class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 text-3xl leading-none">×</button>
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-4">Unsubscribe</h2>
|
||||
<p class="text-gray-600 mb-6">Enter your email to unsubscribe from Munich News Daily:</p>
|
||||
<input
|
||||
type="email"
|
||||
id="unsubscribeEmail"
|
||||
placeholder="Enter your email"
|
||||
class="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:outline-none focus:border-primary mb-4"
|
||||
>
|
||||
<button
|
||||
onclick="unsubscribe()"
|
||||
class="w-full px-6 py-3 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg transition"
|
||||
>
|
||||
Unsubscribe
|
||||
</button>
|
||||
<p id="unsubscribeMessage" class="mt-4 text-sm min-h-[20px]"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,31 +20,51 @@ body {
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
padding: 80px 20px 60px;
|
||||
color: white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 120"><path d="M0,0 Q300,60 600,30 T1200,0 L1200,120 L0,120 Z" fill="rgba(255,255,255,0.1)"/></svg>') no-repeat bottom;
|
||||
background-size: cover;
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3.5rem;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 300;
|
||||
font-weight: 400;
|
||||
text-shadow: 0 1px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 40px;
|
||||
opacity: 0.9;
|
||||
opacity: 0.95;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.subscription-form {
|
||||
@@ -130,6 +150,14 @@ body {
|
||||
padding: 40px;
|
||||
margin: 40px 0;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.news-section {
|
||||
padding: 24px 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.news-section h2 {
|
||||
@@ -142,56 +170,164 @@ body {
|
||||
.news-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 25px;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.news-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) and (min-width: 768px) {
|
||||
.news-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.news-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: #f8f9fa;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid #667eea;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
display: grid;
|
||||
grid-template-rows: 180px 1fr;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.news-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
background: white;
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.news-card-image {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 2.5rem;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.news-card-image.placeholder {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.news-card-content {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.news-card .source {
|
||||
font-size: 0.7rem;
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.news-card .source::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.news-card h3 {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
flex-grow: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-card p {
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
flex-grow: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-card .source {
|
||||
font-size: 0.85rem;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
.news-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.news-card .read-more {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
font-size: 0.85rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: gap 0.3s ease;
|
||||
}
|
||||
|
||||
.news-card .read-more:hover {
|
||||
text-decoration: underline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.news-card .read-more::after {
|
||||
content: '→';
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.news-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -284,8 +420,58 @@ footer a:hover {
|
||||
background: #ff5252;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.news-card {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.news-card:nth-child(1) { animation-delay: 0.1s; }
|
||||
.news-card:nth-child(2) { animation-delay: 0.2s; }
|
||||
.news-card:nth-child(3) { animation-delay: 0.3s; }
|
||||
.news-card:nth-child(4) { animation-delay: 0.4s; }
|
||||
.news-card:nth-child(5) { animation-delay: 0.5s; }
|
||||
.news-card:nth-child(6) { animation-delay: 0.6s; }
|
||||
|
||||
/* Loading Spinner */
|
||||
.loading {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-left: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
padding: 60px 20px 40px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
@@ -296,11 +482,28 @@ footer a:hover {
|
||||
|
||||
.news-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.news-card-image {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.subscription-form {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user