Add a visual sparkle effect for new news updates on the Erling Haaland media outlet
Introduces a new API endpoint `/api/articles` and implements a client-side animation to visually alert users of new articles on the 'erling-haaland' outlet using CSS keyframes and conditional rendering. Replit-Commit-Author: Agent Replit-Commit-Session-Id: d9e77062-eeec-4c95-9131-905f69a78072 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/d9e77062-eeec-4c95-9131-905f69a78072/VftcvwB
This commit is contained in:
4
.replit
4
.replit
@ -30,6 +30,10 @@ externalPort = 3003
|
|||||||
localPort = 43349
|
localPort = 43349
|
||||||
externalPort = 3000
|
externalPort = 3000
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 43777
|
||||||
|
externalPort = 4200
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PORT = "5000"
|
PORT = "5000"
|
||||||
|
|
||||||
|
|||||||
@ -74,6 +74,7 @@ export default function MainContent() {
|
|||||||
const articleCount = articleCountByOutlet[outlet.id] || 0;
|
const articleCount = articleCountByOutlet[outlet.id] || 0;
|
||||||
const hasArticles = articleCount > 0;
|
const hasArticles = articleCount > 0;
|
||||||
const isErlingHaaland = outlet.slug === 'erling-haaland';
|
const isErlingHaaland = outlet.slug === 'erling-haaland';
|
||||||
|
const showSpecialEffect = isErlingHaaland && hasArticles;
|
||||||
|
|
||||||
const animationClass = hasArticles
|
const animationClass = hasArticles
|
||||||
? 'animate-float ring-2 ring-blue-400/30 shadow-lg'
|
? 'animate-float ring-2 ring-blue-400/30 shadow-lg'
|
||||||
@ -87,13 +88,36 @@ export default function MainContent() {
|
|||||||
>
|
>
|
||||||
{hasArticles && (
|
{hasArticles && (
|
||||||
<div className="absolute -top-2 -right-2 z-10">
|
<div className="absolute -top-2 -right-2 z-10">
|
||||||
<Badge className="bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg animate-pulse">
|
<Badge className={`bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg animate-pulse ${showSpecialEffect ? 'animate-glow-pulse' : ''}`}>
|
||||||
<Sparkles className="h-3 w-3 mr-1" />
|
<Sparkles className="h-3 w-3 mr-1" />
|
||||||
NEW
|
NEW
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{showSpecialEffect && (
|
||||||
|
<div className="absolute inset-0 pointer-events-none" data-testid="sparkle-effect-erling">
|
||||||
|
{[...Array(6)].map((_, i) => {
|
||||||
|
const angle = (i * 60) * Math.PI / 180;
|
||||||
|
const distance = 20 + (i % 2) * 10;
|
||||||
|
const x = Math.cos(angle) * distance;
|
||||||
|
const y = Math.sin(angle) * distance;
|
||||||
|
const delay = i * 0.2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute top-1/2 left-1/2 w-2 h-2 rounded-full bg-gradient-to-r from-yellow-400 to-pink-500"
|
||||||
|
style={{
|
||||||
|
animation: `sparkle 2s ease-in-out ${delay}s infinite`,
|
||||||
|
['--sparkle-x' as any]: `${x}px`,
|
||||||
|
['--sparkle-y' as any]: `${y}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardContent className={`p-2 ${isErlingHaaland ? 'animate-shake' : ''}`}>
|
</div>
|
||||||
|
)}
|
||||||
|
<CardContent className="p-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
className={`w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center overflow-hidden cursor-pointer hover:ring-2 hover:ring-blue-400 transition-all ${
|
className={`w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center overflow-hidden cursor-pointer hover:ring-2 hover:ring-blue-400 transition-all ${
|
||||||
|
|||||||
@ -137,11 +137,49 @@
|
|||||||
animation: float 3s ease-in-out infinite;
|
animation: float 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes sparkle {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(0, 0) scale(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(var(--sparkle-x), var(--sparkle-y)) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -100% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 20px rgba(59, 130, 246, 0.5), 0 0 40px rgba(168, 85, 247, 0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 30px rgba(59, 130, 246, 0.8), 0 0 60px rgba(168, 85, 247, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-glow-pulse {
|
||||||
|
animation: glow-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.animate-float {
|
.animate-float {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
.animate-glow-pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-hover:hover {
|
.card-hover:hover {
|
||||||
|
|||||||
@ -69,6 +69,16 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Article routes
|
// Article routes
|
||||||
|
app.get('/api/articles', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const articles = await storage.getArticles();
|
||||||
|
res.json(articles);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching articles:", error);
|
||||||
|
res.status(500).json({ message: "Failed to fetch articles" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/media-outlets/:slug/articles', async (req, res) => {
|
app.get('/api/media-outlets/:slug/articles', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const outlet = await storage.getMediaOutletBySlug(req.params.slug);
|
const outlet = await storage.getMediaOutletBySlug(req.params.slug);
|
||||||
|
|||||||
@ -43,6 +43,7 @@ export interface IStorage {
|
|||||||
updateMediaOutlet(id: string, outlet: Partial<InsertMediaOutlet>): Promise<MediaOutlet>;
|
updateMediaOutlet(id: string, outlet: Partial<InsertMediaOutlet>): Promise<MediaOutlet>;
|
||||||
|
|
||||||
// Article operations
|
// Article operations
|
||||||
|
getArticles(): Promise<Article[]>;
|
||||||
getArticlesByOutlet(mediaOutletId: string): Promise<Article[]>;
|
getArticlesByOutlet(mediaOutletId: string): Promise<Article[]>;
|
||||||
getArticleBySlug(slug: string): Promise<Article | undefined>;
|
getArticleBySlug(slug: string): Promise<Article | undefined>;
|
||||||
createArticle(article: InsertArticle): Promise<Article>;
|
createArticle(article: InsertArticle): Promise<Article>;
|
||||||
@ -144,6 +145,13 @@ export class DatabaseStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Article operations
|
// Article operations
|
||||||
|
async getArticles(): Promise<Article[]> {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(articles)
|
||||||
|
.orderBy(desc(articles.publishedAt));
|
||||||
|
}
|
||||||
|
|
||||||
async getArticlesByOutlet(mediaOutletId: string): Promise<Article[]> {
|
async getArticlesByOutlet(mediaOutletId: string): Promise<Article[]> {
|
||||||
return await db
|
return await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
Reference in New Issue
Block a user