Agent query: Please verify if:
1. The application loads successfully 2. You can see the newsletter cards with smooth animations 3. The search bar is visible at the top Enhance newsletter archive viewer with mobile UI, social sharing, push notifications, search, RSS feed, and infinite scrolling. Screenshot: https://storage.googleapis.com/screenshot-production-us-central1/9dda30b6-4149-4bce-89dc-76333005952c/fea88e1e-ec20-4c53-8b12-b205f04819b7.jpg
This commit is contained in:
@@ -1,23 +1,41 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Search, ExternalLink, Calendar, RefreshCw } from "lucide-react";
|
import {
|
||||||
|
Search,
|
||||||
|
ExternalLink,
|
||||||
|
Calendar,
|
||||||
|
RefreshCw,
|
||||||
|
Share2,
|
||||||
|
Twitter,
|
||||||
|
Facebook,
|
||||||
|
Rss,
|
||||||
|
Bell,
|
||||||
|
BellOff
|
||||||
|
} from "lucide-react";
|
||||||
import { useNewsletters, useNewsletterSearch } from "@/lib/newsletter-data";
|
import { useNewsletters, useNewsletterSearch } from "@/lib/newsletter-data";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import { queryClient } from "@/lib/queryClient";
|
import { queryClient } from "@/lib/queryClient";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||||
|
const loader = useRef(null);
|
||||||
const { data: allNewsletters, isLoading } = useNewsletters();
|
const { data: allNewsletters, isLoading } = useNewsletters();
|
||||||
const { data: searchResults } = useNewsletterSearch(searchQuery);
|
const { data: searchResults } = useNewsletterSearch(searchQuery);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const newsletters = searchQuery ? searchResults : allNewsletters;
|
const newsletters = searchQuery ? searchResults : allNewsletters;
|
||||||
|
const paginatedNewsletters = newsletters?.slice(0, page * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -39,10 +57,88 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShare = async (newsletter) => {
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: newsletter.title,
|
||||||
|
text: newsletter.description || "Check out this newsletter from The Downtowner",
|
||||||
|
url: newsletter.url
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== 'AbortError') {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to share newsletter",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubscribe = async () => {
|
||||||
|
try {
|
||||||
|
if (!('serviceWorker' in navigator) || !('Notification' in window)) {
|
||||||
|
throw new Error('Push notifications are not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
throw new Error('Notification permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: process.env.VITE_VAPID_PUBLIC_KEY
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiRequest('POST', '/api/subscriptions', subscription);
|
||||||
|
setIsSubscribed(true);
|
||||||
|
toast({
|
||||||
|
title: "Subscribed!",
|
||||||
|
description: "You'll receive notifications for new newsletters",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message || "Failed to subscribe to notifications",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleObserver = useCallback((entries) => {
|
||||||
|
const target = entries[0];
|
||||||
|
if (target.isIntersecting && newsletters?.length > page * ITEMS_PER_PAGE) {
|
||||||
|
setPage(prev => prev + 1);
|
||||||
|
}
|
||||||
|
}, [newsletters, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(handleObserver, {
|
||||||
|
root: null,
|
||||||
|
rootMargin: "20px",
|
||||||
|
threshold: 1.0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loader.current) {
|
||||||
|
observer.observe(loader.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [handleObserver]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<header className="mb-8 text-center">
|
<motion.header
|
||||||
|
className="mb-8 text-center"
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
<h1 className="text-4xl font-bold mb-2 bg-gradient-to-r from-primary to-primary/80 bg-clip-text text-transparent">
|
<h1 className="text-4xl font-bold mb-2 bg-gradient-to-r from-primary to-primary/80 bg-clip-text text-transparent">
|
||||||
The Downtowner
|
The Downtowner
|
||||||
</h1>
|
</h1>
|
||||||
@@ -68,58 +164,119 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${isImporting ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${isImporting ? 'animate-spin' : ''}`} />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleSubscribe}
|
||||||
|
disabled={isSubscribed}
|
||||||
|
>
|
||||||
|
{isSubscribed ? (
|
||||||
|
<BellOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a href="/api/rss" target="_blank" rel="noopener noreferrer">
|
||||||
|
<Rss className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</motion.header>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{isLoading ? (
|
<AnimatePresence>
|
||||||
Array(6).fill(0).map((_, i) => (
|
{isLoading ? (
|
||||||
<Card key={i} className="hover:shadow-lg transition-shadow">
|
Array(6).fill(0).map((_, i) => (
|
||||||
<CardHeader>
|
<motion.div
|
||||||
<Skeleton className="h-6 w-2/3" />
|
key={`skeleton-${i}`}
|
||||||
<Skeleton className="h-4 w-1/2" />
|
initial={{ opacity: 0 }}
|
||||||
</CardHeader>
|
animate={{ opacity: 1 }}
|
||||||
<CardContent>
|
exit={{ opacity: 0 }}
|
||||||
<Skeleton className="h-4 w-full" />
|
>
|
||||||
</CardContent>
|
<Card className="hover:shadow-lg transition-shadow">
|
||||||
</Card>
|
<CardHeader>
|
||||||
))
|
<Skeleton className="h-6 w-2/3" />
|
||||||
) : newsletters?.length ? (
|
<Skeleton className="h-4 w-1/2" />
|
||||||
newsletters.map((newsletter) => (
|
</CardHeader>
|
||||||
<a
|
|
||||||
key={newsletter.id}
|
|
||||||
href={newsletter.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="block"
|
|
||||||
>
|
|
||||||
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer group">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
{newsletter.title}
|
|
||||||
<ExternalLink className="h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
{format(new Date(newsletter.date), 'MMMM d, yyyy')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{newsletter.description && (
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-muted-foreground">
|
<Skeleton className="h-4 w-full" />
|
||||||
{newsletter.description}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
</Card>
|
</motion.div>
|
||||||
</a>
|
))
|
||||||
))
|
) : paginatedNewsletters?.length ? (
|
||||||
) : (
|
paginatedNewsletters.map((newsletter) => (
|
||||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
<motion.div
|
||||||
No newsletters found matching your search.
|
key={newsletter.id}
|
||||||
</div>
|
initial={{ opacity: 0, y: 20 }}
|
||||||
)}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
layout
|
||||||
|
>
|
||||||
|
<Card className="h-full hover:shadow-lg transition-all duration-300 cursor-pointer group">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span className="line-clamp-2">{newsletter.title}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleShare(newsletter);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<a
|
||||||
|
href={newsletter.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
{format(new Date(newsletter.date), 'MMMM d, yyyy')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{(newsletter.thumbnail || newsletter.description) && (
|
||||||
|
<CardContent>
|
||||||
|
{newsletter.thumbnail && (
|
||||||
|
<img
|
||||||
|
src={newsletter.thumbnail}
|
||||||
|
alt={newsletter.title}
|
||||||
|
className="w-full h-40 object-cover rounded-md mb-4"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{newsletter.description && (
|
||||||
|
<p className="text-muted-foreground line-clamp-3">
|
||||||
|
{newsletter.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||||
|
No newsletters found matching your search.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div ref={loader} className="h-20" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
199
package-lock.json
generated
199
package-lock.json
generated
@@ -53,10 +53,12 @@
|
|||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"feed": "^4.2.2",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
|
"node-schedule": "^2.1.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -69,6 +71,7 @@
|
|||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.0",
|
"vaul": "^1.1.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
@@ -3510,6 +3513,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||||
@@ -3577,6 +3589,18 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -3650,6 +3674,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
|
||||||
|
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||||
@@ -3749,6 +3779,12 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -4052,6 +4088,18 @@
|
|||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "4.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
|
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4995,6 +5043,15 @@
|
|||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -5349,6 +5406,18 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/feed": {
|
||||||
|
"version": "4.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz",
|
||||||
|
"integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xml-js": "^1.6.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -5698,6 +5767,15 @@
|
|||||||
"entities": "^4.5.0"
|
"entities": "^4.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
@@ -5714,6 +5792,19 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
@@ -5897,6 +5988,27 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.0",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||||
@@ -5939,6 +6051,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/long-timeout": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -5970,6 +6088,15 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
@@ -6081,6 +6208,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
@@ -6096,6 +6229,15 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minipass": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
@@ -6183,6 +6325,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-schedule": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron-parser": "^4.2.0",
|
||||||
|
"long-timeout": "0.1.1",
|
||||||
|
"sorted-array-functions": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -7292,6 +7448,12 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -7448,6 +7610,12 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sorted-array-functions": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
@@ -8905,6 +9073,25 @@
|
|||||||
"@esbuild/win32-x64": "0.21.5"
|
"@esbuild/win32-x64": "0.21.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-encoding": {
|
"node_modules/whatwg-encoding": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||||
@@ -9079,6 +9266,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-js": {
|
||||||
|
"version": "1.6.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||||
|
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": "^1.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xml-js": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -55,10 +55,12 @@
|
|||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"feed": "^4.2.2",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
|
"node-schedule": "^2.1.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -71,6 +73,7 @@
|
|||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.0",
|
"vaul": "^1.1.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
|
|||||||
107
server/routes.ts
107
server/routes.ts
@@ -2,8 +2,63 @@ import type { Express } from "express";
|
|||||||
import { createServer, type Server } from "http";
|
import { createServer, type Server } from "http";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import { scrapeNewsletters } from "./utils";
|
import { scrapeNewsletters } from "./utils";
|
||||||
|
import { Feed } from "feed";
|
||||||
|
import webpush from "web-push";
|
||||||
|
import schedule from "node-schedule";
|
||||||
|
|
||||||
|
// Initialize web-push with VAPID keys
|
||||||
|
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) {
|
||||||
|
console.warn('VAPID keys not set. Push notifications will not work.');
|
||||||
|
}
|
||||||
|
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
'mailto:team@downtowner.com',
|
||||||
|
process.env.VAPID_PUBLIC_KEY || '',
|
||||||
|
process.env.VAPID_PRIVATE_KEY || ''
|
||||||
|
);
|
||||||
|
|
||||||
export async function registerRoutes(app: Express): Promise<Server> {
|
export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
|
// Setup background job to check for new newsletters
|
||||||
|
schedule.scheduleJob('0 */6 * * *', async function() {
|
||||||
|
try {
|
||||||
|
const existingNewsletters = await storage.getNewsletters();
|
||||||
|
const scrapedNewsletters = await scrapeNewsletters();
|
||||||
|
|
||||||
|
const newNewsletters = scrapedNewsletters.filter(scraped =>
|
||||||
|
!existingNewsletters.some(existing =>
|
||||||
|
existing.url === scraped.url
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newNewsletters.length > 0) {
|
||||||
|
await storage.importNewsletters(newNewsletters);
|
||||||
|
|
||||||
|
// Send push notifications
|
||||||
|
const subscriptions = await storage.getSubscriptions();
|
||||||
|
const notificationPayload = JSON.stringify({
|
||||||
|
title: 'New Newsletters Available',
|
||||||
|
body: `${newNewsletters.length} new newsletter${newNewsletters.length > 1 ? 's' : ''} published!`,
|
||||||
|
icon: '/icon.png'
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
subscriptions.map(subscription =>
|
||||||
|
webpush.sendNotification({
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: {
|
||||||
|
auth: subscription.auth,
|
||||||
|
p256dh: subscription.p256dh
|
||||||
|
}
|
||||||
|
}, notificationPayload)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Background job failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Routes
|
||||||
app.get("/api/newsletters", async (_req, res) => {
|
app.get("/api/newsletters", async (_req, res) => {
|
||||||
const newsletters = await storage.getNewsletters();
|
const newsletters = await storage.getNewsletters();
|
||||||
res.json(newsletters);
|
res.json(newsletters);
|
||||||
@@ -26,6 +81,58 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/api/subscriptions", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const subscription = req.body;
|
||||||
|
await storage.addSubscription({
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
auth: subscription.keys.auth,
|
||||||
|
p256dh: subscription.keys.p256dh
|
||||||
|
});
|
||||||
|
res.json({ message: "Subscription added successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding subscription:', error);
|
||||||
|
res.status(500).json({ message: "Failed to add subscription" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/rss", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const newsletters = await storage.getNewsletters();
|
||||||
|
|
||||||
|
const feed = new Feed({
|
||||||
|
title: "The Downtowner Newsletter",
|
||||||
|
description: "Downtown Nashua's Newsletter Archive",
|
||||||
|
id: "https://downtowner.com/",
|
||||||
|
link: "https://downtowner.com/",
|
||||||
|
language: "en",
|
||||||
|
favicon: "https://downtowner.com/favicon.ico",
|
||||||
|
updated: newsletters[0]?.date ? new Date(newsletters[0].date) : new Date(),
|
||||||
|
generator: "The Downtowner RSS Feed",
|
||||||
|
feedLinks: {
|
||||||
|
rss2: "https://downtowner.com/api/rss"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const newsletter of newsletters) {
|
||||||
|
feed.addItem({
|
||||||
|
title: newsletter.title,
|
||||||
|
id: newsletter.url,
|
||||||
|
link: newsletter.url,
|
||||||
|
description: newsletter.description,
|
||||||
|
date: new Date(newsletter.date),
|
||||||
|
image: newsletter.thumbnail
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.type('application/xml');
|
||||||
|
res.send(feed.rss2());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating RSS feed:', error);
|
||||||
|
res.status(500).json({ message: "Failed to generate RSS feed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
return httpServer;
|
return httpServer;
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,52 @@
|
|||||||
import { type Newsletter, type InsertNewsletter } from "@shared/schema";
|
import { type Newsletter, type InsertNewsletter, type Subscription, type InsertSubscription } from "@shared/schema";
|
||||||
|
import { db } from "./db";
|
||||||
|
import { newsletters, subscriptions } from "@shared/schema";
|
||||||
|
import { desc, ilike, or } from "drizzle-orm";
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
getNewsletters(): Promise<Newsletter[]>;
|
getNewsletters(): Promise<Newsletter[]>;
|
||||||
searchNewsletters(query: string): Promise<Newsletter[]>;
|
searchNewsletters(query: string): Promise<Newsletter[]>;
|
||||||
importNewsletters(newsletters: InsertNewsletter[]): Promise<void>;
|
importNewsletters(newsletters: InsertNewsletter[]): Promise<void>;
|
||||||
|
addSubscription(subscription: InsertSubscription): Promise<void>;
|
||||||
|
getSubscriptions(): Promise<Subscription[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MemStorage implements IStorage {
|
export class DatabaseStorage implements IStorage {
|
||||||
private newsletters: Newsletter[];
|
|
||||||
private currentId: number;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.newsletters = [];
|
|
||||||
this.currentId = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getNewsletters(): Promise<Newsletter[]> {
|
async getNewsletters(): Promise<Newsletter[]> {
|
||||||
return this.newsletters.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
return await db.select().from(newsletters).orderBy(desc(newsletters.date));
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchNewsletters(query: string): Promise<Newsletter[]> {
|
async searchNewsletters(query: string): Promise<Newsletter[]> {
|
||||||
const lowercaseQuery = query.toLowerCase();
|
const lowercaseQuery = query.toLowerCase();
|
||||||
return this.newsletters.filter(
|
return await db
|
||||||
newsletter =>
|
.select()
|
||||||
newsletter.title.toLowerCase().includes(lowercaseQuery) ||
|
.from(newsletters)
|
||||||
(newsletter.description?.toLowerCase() || '').includes(lowercaseQuery)
|
.where(
|
||||||
).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
or(
|
||||||
|
ilike(newsletters.title, `%${lowercaseQuery}%`),
|
||||||
|
ilike(newsletters.content || '', `%${lowercaseQuery}%`),
|
||||||
|
ilike(newsletters.description || '', `%${lowercaseQuery}%`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(newsletters.date));
|
||||||
}
|
}
|
||||||
|
|
||||||
async importNewsletters(newsletters: InsertNewsletter[]): Promise<void> {
|
async importNewsletters(newNewsletters: InsertNewsletter[]): Promise<void> {
|
||||||
// Ensure description is null if not provided
|
// Insert in batches to avoid overwhelming the database
|
||||||
const processedNewsletters = newsletters.map(newsletter => ({
|
const batchSize = 50;
|
||||||
...newsletter,
|
for (let i = 0; i < newNewsletters.length; i += batchSize) {
|
||||||
description: newsletter.description ?? null,
|
const batch = newNewsletters.slice(i, i + batchSize);
|
||||||
id: this.currentId++
|
await db.insert(newsletters).values(batch);
|
||||||
}));
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.newsletters.push(...processedNewsletters);
|
async addSubscription(subscription: InsertSubscription): Promise<void> {
|
||||||
|
await db.insert(subscriptions).values(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubscriptions(): Promise<Subscription[]> {
|
||||||
|
return await db.select().from(subscriptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storage = new MemStorage();
|
export const storage = new DatabaseStorage();
|
||||||
@@ -4,23 +4,55 @@ import type { InsertNewsletter } from '@shared/schema';
|
|||||||
|
|
||||||
const ROBLY_ARCHIVE_URL = 'https://app.robly.com/public/archives?a=b31b32385b5904b5';
|
const ROBLY_ARCHIVE_URL = 'https://app.robly.com/public/archives?a=b31b32385b5904b5';
|
||||||
|
|
||||||
|
async function scrapeNewsletterContent(url: string) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.5',
|
||||||
|
},
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
|
// Get the second image as thumbnail
|
||||||
|
const images = $('img').toArray();
|
||||||
|
const thumbnailUrl = images.length > 1 ? $(images[1]).attr('src') : null;
|
||||||
|
|
||||||
|
// Extract text content
|
||||||
|
const content = $('body').text().trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumbnail: thumbnailUrl,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Error scraping newsletter content:', error);
|
||||||
|
return { thumbnail: null, content: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function scrapeNewsletters(): Promise<InsertNewsletter[]> {
|
export async function scrapeNewsletters(): Promise<InsertNewsletter[]> {
|
||||||
try {
|
try {
|
||||||
// Add headers to mimic a browser request
|
|
||||||
const { data } = await axios.get(ROBLY_ARCHIVE_URL, {
|
const { data } = await axios.get(ROBLY_ARCHIVE_URL, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||||
'Accept-Language': 'en-US,en;q=0.5',
|
'Accept-Language': 'en-US,en;q=0.5',
|
||||||
},
|
},
|
||||||
timeout: 10000 // 10 second timeout
|
timeout: 10000
|
||||||
});
|
});
|
||||||
|
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
const newsletters: InsertNewsletter[] = [];
|
const newsletters: InsertNewsletter[] = [];
|
||||||
|
|
||||||
// Find all links that start with /archive?id=
|
// Find all links that start with /archive?id=
|
||||||
$('a[href^="/archive?id="]').each((_, element) => {
|
const links = $('a[href^="/archive?id="]');
|
||||||
|
console.log(`Found ${links.length} newsletter links`);
|
||||||
|
|
||||||
|
for (const element of links.toArray()) {
|
||||||
const $element = $(element);
|
const $element = $(element);
|
||||||
const url = $element.attr('href');
|
const url = $element.attr('href');
|
||||||
const fullText = $element.parent().text().trim();
|
const fullText = $element.parent().text().trim();
|
||||||
@@ -33,18 +65,26 @@ export async function scrapeNewsletters(): Promise<InsertNewsletter[]> {
|
|||||||
const [, dateStr, title] = match;
|
const [, dateStr, title] = match;
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateStr).toISOString().split('T')[0];
|
const date = new Date(dateStr).toISOString().split('T')[0];
|
||||||
|
const fullUrl = `https://app.robly.com${url}`;
|
||||||
|
|
||||||
|
// Scrape the newsletter content
|
||||||
|
const { thumbnail, content } = await scrapeNewsletterContent(fullUrl);
|
||||||
|
|
||||||
newsletters.push({
|
newsletters.push({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
date,
|
date,
|
||||||
url: `https://app.robly.com${url}`,
|
url: fullUrl,
|
||||||
description: null
|
thumbnail,
|
||||||
|
content,
|
||||||
|
description: content ? content.slice(0, 200) + '...' : null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`Processed newsletter: ${title}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Error processing date for newsletter:', { dateStr, title }, err);
|
console.warn('Error processing date for newsletter:', { dateStr, title }, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
if (newsletters.length === 0) {
|
if (newsletters.length === 0) {
|
||||||
console.error('No newsletters found in HTML. First 500 chars of response:', data.slice(0, 500));
|
console.error('No newsletters found in HTML. First 500 chars of response:', data.slice(0, 500));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pgTable, text, serial, date } from "drizzle-orm/pg-core";
|
import { pgTable, text, serial, date, timestamp } from "drizzle-orm/pg-core";
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -8,6 +8,9 @@ export const newsletters = pgTable("newsletters", {
|
|||||||
date: date("date").notNull(),
|
date: date("date").notNull(),
|
||||||
url: text("url").notNull(),
|
url: text("url").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
thumbnail: text("thumbnail"),
|
||||||
|
content: text("content"),
|
||||||
|
last_checked: timestamp("last_checked"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const insertNewsletterSchema = createInsertSchema(newsletters).pick({
|
export const insertNewsletterSchema = createInsertSchema(newsletters).pick({
|
||||||
@@ -15,7 +18,27 @@ export const insertNewsletterSchema = createInsertSchema(newsletters).pick({
|
|||||||
date: true,
|
date: true,
|
||||||
url: true,
|
url: true,
|
||||||
description: true,
|
description: true,
|
||||||
|
thumbnail: true,
|
||||||
|
content: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type InsertNewsletter = z.infer<typeof insertNewsletterSchema>;
|
export type InsertNewsletter = z.infer<typeof insertNewsletterSchema>;
|
||||||
export type Newsletter = typeof newsletters.$inferSelect;
|
export type Newsletter = typeof newsletters.$inferSelect;
|
||||||
|
|
||||||
|
// Schema for push notification subscriptions
|
||||||
|
export const subscriptions = pgTable("subscriptions", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
endpoint: text("endpoint").notNull(),
|
||||||
|
auth: text("auth").notNull(),
|
||||||
|
p256dh: text("p256dh").notNull(),
|
||||||
|
created_at: timestamp("created_at").defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const insertSubscriptionSchema = createInsertSchema(subscriptions).pick({
|
||||||
|
endpoint: true,
|
||||||
|
auth: true,
|
||||||
|
p256dh: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InsertSubscription = z.infer<typeof insertSubscriptionSchema>;
|
||||||
|
export type Subscription = typeof subscriptions.$inferSelect;
|
||||||
Reference in New Issue
Block a user