implemented client side filters, mobile first styling and modals for articles

This commit is contained in:
2025-08-02 01:33:49 +02:00
parent e1f51794af
commit ccc1a90cbe
14 changed files with 1248 additions and 131 deletions

View File

@@ -4,7 +4,7 @@ from pathlib import Path
DB_PATH = Path(os.getenv("DB_NAME", "owlynews.sqlite3"))
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
MIN_CRON_HOURS = float(os.getenv("MIN_CRON_HOURS", 0.5))
MIN_CRON_HOURS = float(os.getenv("MIN_CRON_HOURS", 0.1))
DEFAULT_CRON_HOURS = float(os.getenv("CRON_HOURS", MIN_CRON_HOURS))
CRON_HOURS = max(MIN_CRON_HOURS, DEFAULT_CRON_HOURS)
SYNC_COOLDOWN_MINUTES = int(os.getenv("SYNC_COOLDOWN_MINUTES", 30))
@@ -21,7 +21,7 @@ frontend_path = os.path.join(
)
logging.basicConfig(
level=logging.WARNING,
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

View File

@@ -175,31 +175,53 @@ class DatabaseManager:
A database cursor for executing SQL statements
"""
conn = None
cursor = None
try:
conn = self._get_connection()
cursor = conn.cursor()
if readonly:
conn.execute("BEGIN DEFERRED")
else:
conn.execute("BEGIN IMMEDIATE")
cursor = conn.cursor()
yield cursor
if not readonly:
conn.commit()
else:
# For readonly transactions, we still need to end the transaction
conn.commit()
except sqlite3.OperationalError as e:
if conn:
conn.rollback()
try:
conn.rollback()
except:
pass
if "database is locked" in str(e).lower():
logger.warning(
f"⚠️ Database temporarily locked, operation may need retry: {e}")
raise e
except Exception as e:
if conn:
conn.rollback()
try:
conn.rollback()
except:
pass
raise e
finally:
if cursor:
try:
cursor.close()
except:
pass
if conn:
conn.close()
try:
conn.close()
except:
pass
@contextmanager
def get_cursor_with_retry(self,

View File

@@ -22,6 +22,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from fastapi import Depends, FastAPI, HTTPException, Response, status
from fastapi.staticfiles import StaticFiles
from starlette.responses import JSONResponse
from backend.app.config import (
CRON_HOURS,
@@ -65,51 +66,117 @@ scheduler.start()
@app.get("/news", response_model=List[Dict[str, Any]])
async def get_news(
country: str = "DE",
from_: str = "2025-07-01",
to_: str = datetime.now(timezone.utc).strftime("%Y-%m-%d"),
from_: str = None,
to_: str = None,
timezone_name: str = "UTC",
all_countries: bool = False,
all_dates: bool = False,
db: sqlite3.Cursor = Depends(get_db)
):
"""
Get news articles filtered by country and date range.
Now optimized for concurrent access while scheduler is running.
Now handles client timezone properly and supports multiple countries and all news.
Args:
country: Country code to filter by (default: "DE")
from_: Start date in ISO format (default: "2025-07-01")
to: End date in ISO format (default: current date)
country: Country code(s) to filter by (default: "DE").
For multiple countries, use comma-separated values like "DE,US,GB"
from_: Start date in ISO format (optional)
to_: End date in ISO format (optional)
timezone_name: Client timezone for date interpretation (default: "UTC")
all_countries: If True, returns news from all countries (overrides country param)
all_dates: If True, returns news from all dates (overrides date params)
db: Database cursor dependency
Returns:
List of news articles matching the criteria
"""
try:
datetime.fromisoformat(from_)
datetime.fromisoformat(to_)
from zoneinfo import ZoneInfo
from_ts = int(datetime.fromisoformat(from_).timestamp())
to_ts = int(datetime.fromisoformat(to_).timestamp())
# Handle timezone
try:
client_tz = ZoneInfo(timezone_name)
except Exception:
logger.warning(f"Invalid timezone '{timezone_name}', using UTC")
client_tz = timezone.utc
db.execute(
"""
SELECT id, title, description, url, published, country, created_at
FROM news
WHERE country = ?
AND published BETWEEN ? AND ?
ORDER BY published DESC LIMIT 1000
""",
(country, from_ts, to_ts)
)
where_conditions = []
params = []
return [dict(row) for row in db.fetchall()]
if not all_countries:
countries = [c.strip().upper() for c in country.split(',') if c.strip()]
if len(countries) == 1:
where_conditions.append("country = ?")
params.append(countries[0])
elif len(countries) > 1:
placeholders = ','.join(['?' for _ in countries])
where_conditions.append(f"country IN ({placeholders})")
params.extend(countries)
except ValueError:
raise HTTPException(
400, "Invalid date format. Use ISO format (YYYY-MM-DD)")
if not all_dates and (from_ or to_):
if not from_:
from_ = "2025-01-01" # Default start date
if not to_:
to_ = datetime.now(timezone.utc).strftime("%Y-%m-%d") # Default to today
# Parse and convert dates
from_date_naive = datetime.fromisoformat(from_)
to_date_naive = datetime.fromisoformat(to_)
from_date = from_date_naive.replace(tzinfo=client_tz)
to_date = to_date_naive.replace(tzinfo=client_tz)
# Include the entire end date
to_date = to_date.replace(hour=23, minute=59, second=59)
from_ts = int(from_date.timestamp())
to_ts = int(to_date.timestamp())
where_conditions.append("published BETWEEN ? AND ?")
params.extend([from_ts, to_ts])
logger.info(f"Date range: {from_date} to {to_date} (UTC timestamps: {from_ts} to {to_ts})")
# Build the complete SQL query
base_sql = """
SELECT id, title, summary, url, published, country, created_at
FROM news \
"""
if where_conditions:
sql = base_sql + " WHERE " + " AND ".join(where_conditions)
else:
sql = base_sql
sql += " ORDER BY published DESC LIMIT 1000"
# Log query info
if all_countries and all_dates:
logger.info("Querying ALL news articles (no filters)")
elif all_countries:
logger.info(f"Querying news from ALL countries with date filter")
elif all_dates:
logger.info(f"Querying ALL dates for countries: {country}")
else:
logger.info(f"Querying news: countries={country}, timezone={timezone_name}")
logger.info(f"SQL: {sql}")
logger.info(f"Parameters: {params}")
# Execute the query
db.execute(sql, params)
rows = db.fetchall()
result = [dict(row) for row in rows]
logger.info(f"Found {len(result)} news articles")
return result
except ValueError as e:
logger.error(f"Date parsing error: {e}")
raise HTTPException(400, "Invalid date format. Use ISO format (YYYY-MM-DD)")
except Exception as e:
logger.error(f"❌ Error fetching news: {e}")
raise HTTPException(
500, "Internal server error while fetching news"
)
raise HTTPException(500, "Internal server error while fetching news")
@app.get("/feeds", response_model=List[Dict[str, Any]])
@@ -125,7 +192,7 @@ async def list_feeds(db: sqlite3.Cursor = Depends(get_db)):
"""
try:
db.execute("SELECT * FROM feeds ORDER BY country, url")
return [dict(row) for row in db.fetchall()]
return JSONResponse(content=[dict(row) for row in db.fetchall()])
except Exception as e:
logger.error(f"❌ Error fetching feeds: {e}")
raise HTTPException(
@@ -133,23 +200,6 @@ async def list_feeds(db: sqlite3.Cursor = Depends(get_db)):
)
@app.get("/meta/last-sync", response_model=TimestampResponse)
async def get_last_sync(db: sqlite3.Cursor = Depends(get_db)):
"""
Get the timestamp of the last successful feed synchronization.
Args:
db: Database cursor dependency
Returns:
Object containing the timestamp as a Unix epoch
"""
db.execute("SELECT val FROM meta WHERE key='last_sync'")
row = db.fetchone()
if row is None:
import time
return {"ts": int(time.time())}
return {"ts": int(row["val"])}
@app.post("/feeds", response_model=SuccessResponse)
@@ -271,6 +321,25 @@ async def manual_sync(db: sqlite3.Cursor = Depends(get_db)):
)
@app.get("/meta/last-sync", response_model=TimestampResponse)
async def get_last_sync(db: sqlite3.Cursor = Depends(get_db)):
"""
Get the timestamp of the last successful feed synchronization.
Args:
db: Database cursor dependency
Returns:
Object containing the timestamp as a Unix epoch
"""
db.execute("SELECT val FROM meta WHERE key='last_sync'")
row = db.fetchone()
if row is None:
import time
return {"ts": int(time.time())}
return {"ts": int(row["val"])}
@app.get("/settings/cron", response_model=HoursResponse)
async def get_cron_schedule(db: sqlite3.Cursor = Depends(get_db)):
"""

View File

@@ -4,11 +4,11 @@
CREATE TABLE IF NOT EXISTS news (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
summary TEXT,
url TEXT NOT NULL,
published TIMESTAMP NOT NULL,
country TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at INTEGER DEFAULT (strftime('%s', 'now'))
);
-- Index for faster queries on published date

View File

@@ -126,7 +126,7 @@ class NewsFetcher:
def build_prompt(
url: str,
title: str = "",
description: str = "",
summary: str = "",
content: str = "") -> str:
"""
Generate a prompt for the LLM to summarize an article.
@@ -134,7 +134,7 @@ class NewsFetcher:
Args:
url: Public URL of the article to summarize
title: Article title from RSS feed (optional)
description: Article description from RSS feed (optional)
summary: Article summary from RSS feed (optional)
content: Extracted article content (optional)
Returns:
@@ -144,8 +144,8 @@ class NewsFetcher:
context_info = []
if title:
context_info.append(f"RSS-Titel: {title}")
if description:
context_info.append(f"RSS-Beschreibung: {description}")
if summary:
context_info.append(f"RSS-Beschreibung: {summary}")
if content:
content_preview = content[:500] + \
"..." if len(content) > 500 else content
@@ -164,9 +164,9 @@ class NewsFetcher:
"2. Falls kein Artikel-Inhalt verfügbar ist, nutze RSS-Titel und -Beschreibung\n"
"3. Falls keine ausreichenden Informationen vorliegen, erstelle eine plausible Zusammenfassung basierend auf der URL\n"
"4. Gib ausschließlich **gültiges minifiziertes JSON** zurück kein Markdown, keine Kommentare\n"
"5. Struktur: {\"title\":\"\",\"description\":\"\"}\n"
"5. Struktur: {\"title\":\"\",\"summary\":\"\"}\n"
"6. title: Aussagekräftiger deutscher Titel (max 100 Zeichen)\n"
"7. description: Deutsche Zusammenfassung (zwischen 100 und 160 Wörter)\n"
"7. summary: Deutsche Zusammenfassung (zwischen 100 und 160 Wörter)\n"
"8. Kein Text vor oder nach dem JSON\n\n"
"### Ausgabe\n"
"Jetzt antworte mit dem JSON:"
@@ -177,7 +177,7 @@ class NewsFetcher:
client: httpx.AsyncClient,
url: str,
title: str = "",
description: str = ""
summary: str = ""
) -> Optional[ArticleSummary]:
"""
Generate a summary of an article using the LLM.
@@ -187,7 +187,7 @@ class NewsFetcher:
client: An active httpx AsyncClient for making requests
url: URL of the article to summarize
title: Article title from RSS feed
description: Article description from RSS feed
summary: Article summary from RSS feed
Returns:
A dictionary containing the article title and summaries in German and English,
@@ -200,7 +200,7 @@ class NewsFetcher:
f"⚠️ Could not fetch article content, using RSS data only")
prompt = NewsFetcher.build_prompt(
url, title, description, article_content)
url, title, summary, article_content)
payload = {
"model": LLM_MODEL,
"prompt": prompt,
@@ -226,7 +226,7 @@ class NewsFetcher:
summary_data = llm_response
# Validate required fields
required_fields = ["title", "description"]
required_fields = ["title", "summary"]
missing_fields = [
field for field in required_fields if field not in summary_data]
@@ -237,12 +237,12 @@ class NewsFetcher:
return None
# Check summary quality metrics
description = len(summary_data.get("description", "").split())
summary_length = len(summary_data.get("summary", "").split())
if description > 160 or description < 100:
if summary_length > 160:
logger.warning(
f"⚠️ Summary exceeds word limit - "
f"Description: {description}/160"
f"Summary: {summary_length}/160"
)
return cast(ArticleSummary, summary_data)
@@ -373,7 +373,7 @@ class NewsFetcher:
f"⚠️ Database check failed for article {i}, continuing: {db_error}")
rss_title = getattr(entry, 'title', '')
rss_description = getattr(
rss_summary = getattr(
entry, 'description', '') or getattr(
entry, 'summary', '')
@@ -381,9 +381,11 @@ class NewsFetcher:
client,
article_url,
title=rss_title,
description=rss_description
summary=rss_summary
)
logger.info(summary)
if not summary:
logger.warning(
f"❌ Failed to get summary for article {i}: {article_url}")
@@ -398,12 +400,12 @@ class NewsFetcher:
"""
INSERT
OR IGNORE INTO news
(title, description, url, published, country)
(title, summary, url, published, country)
VALUES (?, ?, ?, ?, ?)
""",
(
summary["title"],
summary["description"],
summary["summary"],
article_url,
published_timestamp,
feed_row["country"],

View File

@@ -1,41 +1,131 @@
<template>
<div id="app" class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="bg-white shadow-sm border-b sticky top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<h1 class="text-xl sm:text-2xl font-bold text-gray-900">Owly News</h1>
<!-- Mobile Menu Button -->
<button
@click="showMobileMenu = !showMobileMenu"
class="lg:hidden p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Desktop Navigation -->
<nav class="hidden lg:flex space-x-4">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="[
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
activeTab === tab.id
? 'bg-green-100 text-green-700'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
]"
>
{{ tab.label }}
</button>
</nav>
</div>
<!-- Mobile Navigation -->
<div
:class="{ 'hidden': !showMobileMenu }"
class="lg:hidden py-2 border-t"
>
<div class="flex flex-col space-y-1">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id; showMobileMenu = false"
:class="[
'px-3 py-2 rounded-md text-left text-base font-medium transition-colors',
activeTab === tab.id
? 'bg-green-100 text-green-700'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
]"
>
{{ tab.label }}
</button>
</div>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8">
<!-- Management Tab -->
<div v-if="activeTab === 'management'" class="space-y-6">
<!-- Control Panel - Responsive Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<SyncButton />
<NewsRefreshButton />
<ModelStatus />
</div>
<!-- Management Tools -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<FeedManager />
<CronSlider />
</div>
</div>
<!-- News Tab -->
<div v-if="activeTab === 'news'" class="space-y-6">
<!-- News Filters -->
<NewsFilters />
<!-- News List -->
<NewsList />
</div>
</main>
</div>
</template>
<script setup lang="ts">
import {onMounted, ref} from 'vue';
import {useNews} from './stores/useNews';
import FeedManager from './components/FeedManager.vue';
import CronSlider from './components/CronSlider.vue';
import { ref, onMounted } from 'vue';
import { useNews } from './stores/useNews';
import SyncButton from './components/SyncButton.vue';
import NewsRefreshButton from './components/NewsRefreshButton.vue';
import ModelStatus from './components/ModelStatus.vue';
import FeedManager from './components/FeedManager.vue';
import CronSlider from './components/CronSlider.vue';
import NewsFilters from './components/NewsFilters.vue';
import NewsList from './components/NewsList.vue';
const news = useNews();
const filters = ref({country: 'DE'});
const activeTab = ref('news');
const showMobileMenu = ref(false);
const tabs = [
{ id: 'news', label: 'News' },
{ id: 'management', label: 'Management' }
];
onMounted(async () => {
await news.loadLastSync();
await news.sync(filters.value);
await news.getNews(filters.value);
});
// Close mobile menu when clicking outside
document.addEventListener('click', (e) => {
if (showMobileMenu.value && !(e.target as Element).closest('header')) {
showMobileMenu.value = false;
}
});
</script>
<template>
<main class="max-w-4xl mx-auto p-4 space-y-6">
<h1 class="text-2xl font-bold">📰 Local News Summariser</h1>
<div class="grid md:grid-cols-4 gap-4">
<CronSlider/>
<SyncButton/>
<NewsRefreshButton/>
<ModelStatus/>
</div>
<FeedManager/>
<article v-for="a in news.articles" :key="a.id" class="bg-white rounded p-4 shadow">
<h2 class="font-semibold">{{ a.title }}</h2>
<p class="text-sm text-gray-600">{{ new Date(a.published).toLocaleString() }} {{ a.country }}</p>
<p>{{a.published}}</p>
<p class="mt-2">{{ a.description }}</p>
<p class="italic mt-2 text-sm text-gray-700">Added: {{ new Date(a.created_at).toLocaleString() }}</p>
<a :href="a.url" target="_blank" class="text-blue-600 hover:underline">Original </a>
</article>
</main>
</template>
<style scoped>
/* Component-scoped styles to prevent global conflicts */
#app {
display: block;
grid-template-columns: none;
padding: 0;
margin: 0;
max-width: none;
}
</style>

View File

@@ -1,13 +1,7 @@
@import './base.css';
/* Clean main.css without conflicting styles */
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
/* Keep only the link styles you want */
a.custom-link,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
@@ -16,20 +10,10 @@ a,
}
@media (hover: hover) {
a:hover {
a.custom-link:hover,
.green:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}
/* Add any other custom styles you need here, but avoid layout-breaking rules */

View File

@@ -0,0 +1,241 @@
<template>
<Teleport to="body">
<Transition
name="modal"
enter-active-class="modal-enter-active"
leave-active-class="modal-leave-active"
enter-from-class="modal-enter-from"
enter-to-class="modal-enter-to"
leave-from-class="modal-leave-from"
leave-to-class="modal-leave-to"
>
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-md bg-white/20"
@click="closeModal"
>
<div
class="bg-white/95 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 max-w-2xl w-full max-h-[90vh] overflow-hidden modal-content"
@click.stop
>
<!-- Modal Header with glass effect -->
<div class="sticky top-0 bg-white/80 backdrop-blur-sm border-b border-white/30 px-6 py-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<!-- Country badge with glass effect -->
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-white/60 backdrop-blur-sm text-gray-800 border border-white/30 mb-2">
{{ article?.country }}
</span>
<!-- Title -->
<h3 class="text-lg font-semibold text-gray-900 leading-6 pr-4">
{{ article?.title }}
</h3>
<!-- Date -->
<time
v-if="article"
:datetime="new Date(article.published * 1000).toISOString()"
class="text-sm text-gray-600 block mt-2"
>
{{ formatDate(article.published) }}
</time>
</div>
<!-- Close button with glass effect -->
<button
@click="closeModal"
class="flex-shrink-0 bg-white/60 backdrop-blur-sm rounded-full p-2 text-gray-500 hover:text-gray-700 hover:bg-white/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 border border-white/30 transition-all duration-200"
>
<span class="sr-only">Close</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Modal Body with glass scrollbar -->
<div class="px-6 py-4 overflow-y-auto custom-scrollbar">
<!-- Full Summary -->
<div class="mb-6">
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
<svg class="w-4 h-4 mr-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Summary
</h4>
<div class="prose prose-sm max-w-none">
<p class="text-gray-800 leading-relaxed whitespace-pre-wrap">{{ article?.summary }}</p>
</div>
</div>
</div>
<!-- Modal Footer with glass effect -->
<div class="sticky bottom-0 bg-white/80 backdrop-blur-sm border-t border-white/30 px-6 py-4 flex flex-col sm:flex-row gap-3 sm:justify-between">
<button
@click="closeModal"
class="inline-flex justify-center px-4 py-2 text-sm font-medium text-gray-700 bg-white/60 backdrop-blur-sm border border-white/40 rounded-lg shadow-sm hover:bg-white/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200"
>
Close
</button>
<a
v-if="article"
:href="article.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-green-600/90 backdrop-blur-sm border border-green-500/30 rounded-lg shadow-sm hover:bg-green-700/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200"
>
Read Full Article
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { watch, onUnmounted } from 'vue';
interface Article {
id: number;
title: string;
summary: string;
url: string;
published: number;
country: string;
created_at: number;
}
interface Props {
isOpen: boolean;
article: Article | null;
}
interface Emits {
(e: 'close'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const closeModal = () => {
emit('close');
};
// Handle Escape key and body scroll lock
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeModal();
}
};
watch(
() => props.isOpen,
(isOpen) => {
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
} else {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
}
}
);
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
});
function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
if (diffInHours < 1) {
return 'Just now';
} else if (diffInHours < 24) {
return `${diffInHours}h ago`;
} else if (diffInHours < 48) {
return 'Yesterday';
} else {
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
}
</script>
<style scoped>
/* Custom scrollbar for the modal content */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(16, 185, 129, 0.3);
border-radius: 3px;
backdrop-filter: blur(4px);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(16, 185, 129, 0.5);
}
/* Fast Modal Animations with Glass Effect */
.modal-enter-active,
.modal-leave-active {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
backdrop-filter: blur(0px);
}
.modal-enter-to,
.modal-leave-from {
opacity: 1;
backdrop-filter: blur(12px);
}
/* Modal content scaling animation */
.modal-content {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-enter-from .modal-content,
.modal-leave-to .modal-content {
transform: scale(0.95) translateY(-10px);
opacity: 0;
}
.modal-enter-to .modal-content,
.modal-leave-from .modal-content {
transform: scale(1) translateY(0);
opacity: 1;
}
/* Enhanced glass effect during transitions */
.modal-enter-active .modal-content,
.modal-leave-active .modal-content {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>

View File

@@ -0,0 +1,393 @@
<template>
<div class="bg-white rounded-lg shadow-sm border overflow-hidden">
<!-- Mobile Header with Toggle -->
<div class="lg:hidden">
<button
@click="showFilters = !showFilters"
class="w-full px-4 py-3 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
>
<span class="font-medium text-gray-900">Filters</span>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">{{ articleCount }} articles</span>
<svg
:class="{ 'rotate-180': showFilters }"
class="w-5 h-5 text-gray-400 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
</div>
<!-- Desktop Header -->
<div class="hidden lg:block px-6 py-4 border-b">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">News Filters</h3>
<span class="text-sm text-gray-500">{{ articleCount }} articles found</span>
</div>
</div>
<!-- Filters Content -->
<div
:class="{ 'hidden': !showFilters }"
class="lg:block p-4 lg:p-6"
>
<!-- Mobile/Tablet: Stacked Layout -->
<div class="lg:hidden space-y-6">
<!-- Country Filter Mobile -->
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="font-medium text-gray-900 mb-3">Countries</h4>
<div class="space-y-3">
<label class="flex items-center">
<input
type="checkbox"
v-model="filters.allCountries"
@change="onAllCountriesChange"
class="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span class="ml-3 text-sm font-medium">All Countries</span>
</label>
<div v-if="!filters.allCountries" class="grid grid-cols-2 gap-2 pl-6">
<label v-for="country in availableCountries" :key="country" class="flex items-center">
<input
type="checkbox"
:value="country"
v-model="filters.selectedCountries"
class="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span class="ml-2 text-sm">{{ country }}</span>
</label>
</div>
</div>
</div>
<!-- Date Filter Mobile -->
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="font-medium text-gray-900 mb-3">Date Range</h4>
<div class="space-y-3">
<label class="flex items-center">
<input
type="checkbox"
v-model="filters.allDates"
@change="onAllDatesChange"
class="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span class="ml-3 text-sm font-medium">All Dates</span>
</label>
<div v-if="!filters.allDates" class="space-y-3 pl-6">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">From</label>
<input
type="date"
v-model="filters.fromDate"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-green-500 focus:border-green-500"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">To</label>
<input
type="date"
v-model="filters.toDate"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-green-500 focus:border-green-500"
/>
</div>
</div>
<!-- Quick Presets Mobile -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-2">Quick Presets</label>
<div class="grid grid-cols-2 gap-2">
<button
v-for="preset in datePresets"
:key="preset.label"
@click="applyDatePreset(preset)"
class="px-3 py-2 text-sm text-green-600 bg-green-50 hover:bg-green-100 rounded-md transition-colors"
>
{{ preset.label }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons Mobile -->
<div class="flex space-x-3">
<button
@click="applyFilters"
:disabled="isLoading"
class="flex-1 px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium transition-colors"
>
<span v-if="isLoading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading...
</span>
<span v-else>Apply Filters</span>
</button>
<button
@click="resetFilters"
class="px-4 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium transition-colors"
>
Reset
</button>
</div>
</div>
<!-- Desktop: Grid Layout -->
<div class="hidden lg:grid lg:grid-cols-4 gap-6">
<!-- Country Filter Desktop -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Countries</label>
<div class="space-y-2">
<label class="flex items-center">
<input
type="checkbox"
v-model="filters.allCountries"
@change="onAllCountriesChange"
class="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span class="ml-2 text-sm font-medium">All Countries</span>
</label>
<div v-if="!filters.allCountries" class="space-y-1 max-h-40 overflow-y-auto">
<label v-for="country in availableCountries" :key="country" class="flex items-center">
<input
type="checkbox"
:value="country"
v-model="filters.selectedCountries"
class="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span class="ml-2 text-sm">{{ country }}</span>
</label>
</div>
</div>
</div>
<!-- Date Range Filter Desktop -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Date Range</label>
<div class="space-y-2">
<label class="flex items-center">
<input
type="checkbox"
v-model="filters.allDates"
@change="onAllDatesChange"
class="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span class="ml-2 text-sm font-medium">All Dates</span>
</label>
<div v-if="!filters.allDates" class="space-y-2">
<div>
<label class="block text-xs text-gray-500 mb-1">From</label>
<input
type="date"
v-model="filters.fromDate"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-green-500 focus:border-green-500"
/>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">To</label>
<input
type="date"
v-model="filters.toDate"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-green-500 focus:border-green-500"
/>
</div>
</div>
</div>
</div>
<!-- Quick Date Presets Desktop -->
<div v-if="!filters.allDates">
<label class="block text-sm font-medium text-gray-700 mb-3">Quick Presets</label>
<div class="space-y-1">
<button
v-for="preset in datePresets"
:key="preset.label"
@click="applyDatePreset(preset)"
class="block w-full text-left px-3 py-2 text-sm text-green-600 hover:bg-green-50 rounded-md transition-colors"
>
{{ preset.label }}
</button>
</div>
</div>
<!-- Action Buttons Desktop -->
<div class="flex flex-col space-y-3">
<button
@click="applyFilters"
:disabled="isLoading"
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium transition-colors"
>
<span v-if="isLoading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading...
</span>
<span v-else>Apply Filters</span>
</button>
<button
@click="resetFilters"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium transition-colors"
>
Reset
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useNews } from '../stores/useNews';
import { useFeeds } from '../stores/useFeeds';
const news = useNews();
const feeds = useFeeds();
const isLoading = ref(false);
const showFilters = ref(false);
interface DatePreset {
label: string;
fromDate: string;
toDate: string;
}
const filters = ref({
allCountries: false,
selectedCountries: ['DE'] as string[],
allDates: false,
fromDate: '',
toDate: ''
});
// Get available countries from feeds
const availableCountries = computed(() => {
const countries = new Set(feeds.list.map(feed => feed.country));
return Array.from(countries).sort();
});
const articleCount = computed(() => news.articles.length);
// Date presets
const datePresets: DatePreset[] = [
{
label: 'Today',
fromDate: new Date().toISOString().split('T')[0],
toDate: new Date().toISOString().split('T')[0]
},
{
label: '3 days',
fromDate: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
toDate: new Date().toISOString().split('T')[0]
},
{
label: 'Week',
fromDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
toDate: new Date().toISOString().split('T')[0]
},
{
label: 'Month',
fromDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
toDate: new Date().toISOString().split('T')[0]
}
];
// Initialize default dates
onMounted(async () => {
// Set default date range (last 7 days)
filters.value.toDate = new Date().toISOString().split('T')[0];
filters.value.fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
// Load feeds to get available countries
await feeds.loadFeeds();
// Load initial news
await applyFilters();
});
function onAllCountriesChange() {
if (filters.value.allCountries) {
filters.value.selectedCountries = [];
} else {
filters.value.selectedCountries = ['DE']; // Default to DE
}
}
function onAllDatesChange() {
if (filters.value.allDates) {
filters.value.fromDate = '';
filters.value.toDate = '';
} else {
// Reset to last week
filters.value.toDate = new Date().toISOString().split('T')[0];
filters.value.fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
}
}
function applyDatePreset(preset: DatePreset) {
filters.value.fromDate = preset.fromDate;
filters.value.toDate = preset.toDate;
}
async function applyFilters() {
isLoading.value = true;
// Auto-close mobile filters after applying
if (window.innerWidth < 1024) {
showFilters.value = false;
}
try {
const queryParams: Record<string, string | boolean> = {};
// Handle countries
if (filters.value.allCountries) {
queryParams.all_countries = true;
} else if (filters.value.selectedCountries.length > 0) {
queryParams.country = filters.value.selectedCountries.join(',');
}
// Handle dates
if (filters.value.allDates) {
queryParams.all_dates = true;
} else {
if (filters.value.fromDate) {
queryParams.from_ = filters.value.fromDate;
}
if (filters.value.toDate) {
queryParams.to_ = filters.value.toDate;
}
}
await news.getNews(queryParams);
} catch (error) {
console.error('Failed to apply filters:', error);
} finally {
isLoading.value = false;
}
}
function resetFilters() {
filters.value = {
allCountries: false,
selectedCountries: ['DE'],
allDates: false,
fromDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
toDate: new Date().toISOString().split('T')[0]
};
applyFilters();
}
</script>

View File

@@ -0,0 +1,156 @@
<template>
<div>
<!-- Empty State -->
<div v-if="news.articles.length === 0" class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h4" />
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No articles found</h3>
<p class="mt-2 text-gray-500">Try adjusting your filters to see more results.</p>
</div>
<!-- Articles Grid -->
<div v-else class="grid gap-4 sm:gap-6 md:grid-cols-2 xl:grid-cols-3">
<article
v-for="article in news.articles"
:key="article.id"
class="bg-white rounded-lg shadow-sm border hover:shadow-md transition-all duration-200 overflow-hidden group"
>
<!-- Article Header -->
<div class="p-4 sm:p-6">
<div class="flex items-start justify-between mb-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ article.country }}
</span>
<time
:datetime="new Date(article.published * 1000).toISOString()"
class="text-xs text-gray-500 flex-shrink-0 ml-2"
>
{{ formatDate(article.published) }}
</time>
</div>
<!-- Title -->
<h3 class="text-base sm:text-lg font-semibold text-gray-900 mb-3 line-clamp-2 group-hover:text-green-600 transition-colors">
<a :href="article.url" target="_blank" rel="noopener noreferrer">
{{ article.title }}
</a>
</h3>
<!-- Summary -->
<p class="text-sm sm:text-base text-gray-700 line-clamp-3 mb-4 leading-relaxed">
{{ article.summary }}
</p>
<!-- View Summary Button -->
<button
@click="openModal(article)"
class="inline-flex items-center text-sm font-medium text-green-600 hover:text-green-800 transition-colors mb-3"
>
View full summary
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</div>
<!-- Article Footer -->
<div class="px-4 sm:px-6 pb-4 sm:pb-6">
<a
:href="article.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center text-sm font-medium text-green-600 hover:text-green-800 transition-colors"
>
Read full article
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</article>
</div>
<!-- Load More Button (if you want to add pagination later) -->
<div v-if="news.articles.length > 0" class="text-center mt-8">
<p class="text-sm text-gray-500">
Showing {{ news.articles.length }} articles
</p>
</div>
<!-- Article Modal -->
<ArticleModal
:is-open="isModalOpen"
:article="selectedArticle"
@close="closeModal"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useNews } from '../stores/useNews';
import ArticleModal from './ArticleModal.vue';
const news = useNews();
// Modal state
const isModalOpen = ref(false);
const selectedArticle = ref<{
id: number;
title: string;
summary: string;
url: string;
published: number;
country: string;
created_at: number;
} | null>(null);
// Modal functions
const openModal = (article: typeof selectedArticle.value) => {
selectedArticle.value = article;
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
selectedArticle.value = null;
};
function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
if (diffInHours < 1) {
return 'Just now';
} else if (diffInHours < 24) {
return `${diffInHours}h ago`;
} else if (diffInHours < 48) {
return 'Yesterday';
} else {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
}
}
</script>
<style scoped>
.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>

View File

@@ -11,7 +11,7 @@ async function click() {
</script>
<template>
<button :disabled="!news.canManualSync()" @click="click"
class="px-4 py-2 rounded bg-blue-600 disabled:bg-gray-400 text-white">
class="px-4 py-2 rounded bg-green-600 disabled:bg-gray-400 text-white">
Sync now
</button>
<p v-if="!news.canManualSync()" class="text-xs text-gray-500 mt-1">

View File

@@ -16,6 +16,17 @@ export const useFeeds = defineStore('feeds', {
async remove(url: string) {
await fetch(`/feeds?url=${encodeURIComponent(url)}`, {method: 'DELETE'});
await this.fetch();
},
async loadFeeds() {
try {
const response = await fetch('/feeds');
if (response.ok) {
const data = await response.json();
this.list = data;
}
} catch (error) {
console.error('Failed to load feeds:', error);
}
}
}
});

View File

@@ -5,12 +5,14 @@ export const useNews = defineStore('news', {
articles: [] as {
id: number,
title: string,
description: string,
summary: string,
url: string,
published: number,
country: string,
created_at: number
}[], lastSync: 0
}[],
lastSync: 0,
clientTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone
}),
actions: {
async loadLastSync() {
@@ -20,13 +22,18 @@ export const useNews = defineStore('news', {
canManualSync() {
return Date.now() - this.lastSync > 30 * 60 * 1000; // 30min guard
},
async sync(filters: Record<string, string>) {
async sync(filters: Record<string, string | boolean> = {}) {
if (!this.canManualSync()) {
console.log('Too soon to sync again');
return;
}
const q = new URLSearchParams(filters).toString();
// Include timezone in the request
const filtersWithTz = {
...filters,
timezone: this.clientTimezone
};
const q = new URLSearchParams(filtersWithTz as Record<string, string>).toString();
const res = await fetch(`/news?${q}`);
if (res.ok) {
@@ -35,17 +42,45 @@ export const useNews = defineStore('news', {
this.lastSync = Date.now();
}
},
async getNews(filters: Record<string, string> = {}) {
const q = new URLSearchParams(filters).toString();
const res = await fetch(`/news?${q}`);
async getNews(filters: Record<string, string | boolean> = {}) {
try {
const filtersWithTz = {
...filters,
timezone: this.clientTimezone
};
const q = new URLSearchParams(filtersWithTz as Record<string, string>).toString();
const res = await fetch(`/news?${q}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const contentType = res.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw new Error('Response is not JSON');
}
if (res.ok) {
const data = await res.json();
this.articles = data;
return data;
} catch (error) {
console.error('Failed to get news:', error);
return [];
}
},
return [];
// New convenience methods
async getAllNews() {
return this.getNews({ all_countries: 'true', all_dates: 'true' });
},
async getNewsForCountries(countries: string[], from?: string, to?: string) {
const filters: Record<string, string> = {
country: countries.join(',')
};
if (from) filters.from_ = from;
if (to) filters.to_ = to;
return this.getNews(filters);
}
}
});

View File

@@ -1 +1,115 @@
@import "tailwindcss";
@import "assets/main.css";
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Smooth transitions for better mobile experience */
* {
-webkit-tap-highlight-color: transparent;
}
/* Focus styles for better accessibility */
button:focus,
input:focus,
select:focus,
textarea:focus {
outline: 2px solid #10b981;
outline-offset: 2px;
}
/* Loading animation */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Enhanced responsive utilities */
@media (max-width: 640px) {
.container {
padding-left: 1rem;
padding-right: 1rem;
}
}
/* Custom line clamp utilities */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.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;
}
/* Mobile-first button styles */
button {
min-height: 44px; /* iOS recommended touch target */
}
@media (min-width: 1024px) {
button {
min-height: auto;
}
}
/* Enhanced mobile card styles */
@media (max-width: 768px) {
.card-mobile {
margin-left: -1rem;
margin-right: -1rem;
border-radius: 0;
border-left: none;
border-right: none;
}
}
/* Reset any conflicting styles specifically for the app */
#app {
display: block !important;
grid-template-columns: none !important;
padding: 0 !important;
margin: 0 !important;
max-width: none !important;
}
/* Ensure body doesn't interfere with the layout */
body {
display: block !important;
place-items: unset !important;
}