implemented client side filters, mobile first styling and modals for articles
This commit is contained in:
@@ -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>
|
||||
|
@@ -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 */
|
||||
|
241
frontend/src/components/ArticleModal.vue
Normal file
241
frontend/src/components/ArticleModal.vue
Normal 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>
|
393
frontend/src/components/NewsFilters.vue
Normal file
393
frontend/src/components/NewsFilters.vue
Normal 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>
|
156
frontend/src/components/NewsList.vue
Normal file
156
frontend/src/components/NewsList.vue
Normal 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>
|
@@ -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">
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -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; // 30‑min 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user