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:
TerribleDev
2025-02-15 17:57:15 +00:00
parent 34beb772af
commit dee280d055
7 changed files with 619 additions and 81 deletions

View File

@@ -1,23 +1,41 @@
import { useState } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { format } from "date-fns";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
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 { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { queryClient } from "@/lib/queryClient";
import { motion, AnimatePresence } from "framer-motion";
const ITEMS_PER_PAGE = 20;
export default function Home() {
const [searchQuery, setSearchQuery] = useState("");
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: searchResults } = useNewsletterSearch(searchQuery);
const { toast } = useToast();
const newsletters = searchQuery ? searchResults : allNewsletters;
const paginatedNewsletters = newsletters?.slice(0, page * ITEMS_PER_PAGE);
const handleImport = async () => {
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 (
<div className="min-h-screen bg-background">
<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">
The Downtowner
</h1>
@@ -68,13 +164,41 @@ export default function Home() {
>
<RefreshCw className={`h-4 w-4 ${isImporting ? 'animate-spin' : ''}`} />
</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>
</header>
</motion.header>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<AnimatePresence>
{isLoading ? (
Array(6).fill(0).map((_, i) => (
<Card key={i} className="hover:shadow-lg transition-shadow">
<motion.div
key={`skeleton-${i}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-1/2" />
@@ -83,43 +207,76 @@ export default function Home() {
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
</motion.div>
))
) : newsletters?.length ? (
newsletters.map((newsletter) => (
<a
) : paginatedNewsletters?.length ? (
paginatedNewsletters.map((newsletter) => (
<motion.div
key={newsletter.id}
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"
className="block"
onClick={(e) => e.stopPropagation()}
>
<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" />
<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.description && (
{(newsletter.thumbnail || newsletter.description) && (
<CardContent>
<p className="text-muted-foreground">
{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>
</a>
</motion.div>
))
) : (
<div className="col-span-full text-center py-12 text-muted-foreground">
No newsletters found matching your search.
</div>
)}
</AnimatePresence>
</div>
<div ref={loader} className="h-20" />
</div>
</div>
);

199
package-lock.json generated
View File

@@ -53,10 +53,12 @@
"embla-carousel-react": "^8.3.0",
"express": "^4.21.2",
"express-session": "^1.18.1",
"feed": "^4.2.2",
"framer-motion": "^11.13.1",
"input-otp": "^1.2.4",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
"node-schedule": "^2.1.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"react": "^18.3.1",
@@ -69,6 +71,7 @@
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.0",
"web-push": "^3.6.7",
"wouter": "^3.3.5",
"ws": "^8.18.0",
"zod": "^3.23.8",
@@ -3510,6 +3513,15 @@
"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": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
@@ -3577,6 +3589,18 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3650,6 +3674,12 @@
"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": {
"version": "1.20.3",
"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_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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -4052,6 +4088,18 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"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": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4995,6 +5043,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -5349,6 +5406,18 @@
"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": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5698,6 +5767,15 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -5714,6 +5792,19 @@
"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": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -5897,6 +5988,27 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
@@ -5939,6 +6051,12 @@
"dev": true,
"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": {
"version": "1.4.0",
"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"
}
},
"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": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -6081,6 +6208,12 @@
"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": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -6096,6 +6229,15 @@
"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": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@@ -6183,6 +6325,20 @@
"dev": true,
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -7292,6 +7448,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"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": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -7448,6 +7610,12 @@
"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": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -8905,6 +9073,25 @@
"@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": {
"version": "3.1.1",
"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": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -55,10 +55,12 @@
"embla-carousel-react": "^8.3.0",
"express": "^4.21.2",
"express-session": "^1.18.1",
"feed": "^4.2.2",
"framer-motion": "^11.13.1",
"input-otp": "^1.2.4",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
"node-schedule": "^2.1.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"react": "^18.3.1",
@@ -71,6 +73,7 @@
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.0",
"web-push": "^3.6.7",
"wouter": "^3.3.5",
"ws": "^8.18.0",
"zod": "^3.23.8",

View File

@@ -2,8 +2,63 @@ import type { Express } from "express";
import { createServer, type Server } from "http";
import { storage } from "./storage";
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> {
// 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) => {
const newsletters = await storage.getNewsletters();
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);
return httpServer;
}

View File

@@ -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 {
getNewsletters(): Promise<Newsletter[]>;
searchNewsletters(query: string): Promise<Newsletter[]>;
importNewsletters(newsletters: InsertNewsletter[]): Promise<void>;
addSubscription(subscription: InsertSubscription): Promise<void>;
getSubscriptions(): Promise<Subscription[]>;
}
export class MemStorage implements IStorage {
private newsletters: Newsletter[];
private currentId: number;
constructor() {
this.newsletters = [];
this.currentId = 1;
}
export class DatabaseStorage implements IStorage {
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[]> {
const lowercaseQuery = query.toLowerCase();
return this.newsletters.filter(
newsletter =>
newsletter.title.toLowerCase().includes(lowercaseQuery) ||
(newsletter.description?.toLowerCase() || '').includes(lowercaseQuery)
).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return await db
.select()
.from(newsletters)
.where(
or(
ilike(newsletters.title, `%${lowercaseQuery}%`),
ilike(newsletters.content || '', `%${lowercaseQuery}%`),
ilike(newsletters.description || '', `%${lowercaseQuery}%`)
)
)
.orderBy(desc(newsletters.date));
}
async importNewsletters(newsletters: InsertNewsletter[]): Promise<void> {
// Ensure description is null if not provided
const processedNewsletters = newsletters.map(newsletter => ({
...newsletter,
description: newsletter.description ?? null,
id: this.currentId++
}));
this.newsletters.push(...processedNewsletters);
async importNewsletters(newNewsletters: InsertNewsletter[]): Promise<void> {
// Insert in batches to avoid overwhelming the database
const batchSize = 50;
for (let i = 0; i < newNewsletters.length; i += batchSize) {
const batch = newNewsletters.slice(i, i + batchSize);
await db.insert(newsletters).values(batch);
}
}
export const storage = new MemStorage();
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 DatabaseStorage();

View File

@@ -4,23 +4,55 @@ import type { InsertNewsletter } from '@shared/schema';
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[]> {
try {
// Add headers to mimic a browser request
const { data } = await axios.get(ROBLY_ARCHIVE_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: 10000 // 10 second timeout
timeout: 10000
});
const $ = cheerio.load(data);
const newsletters: InsertNewsletter[] = [];
// 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 url = $element.attr('href');
const fullText = $element.parent().text().trim();
@@ -33,18 +65,26 @@ export async function scrapeNewsletters(): Promise<InsertNewsletter[]> {
const [, dateStr, title] = match;
try {
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({
title: title.trim(),
date,
url: `https://app.robly.com${url}`,
description: null
url: fullUrl,
thumbnail,
content,
description: content ? content.slice(0, 200) + '...' : null
});
console.log(`Processed newsletter: ${title}`);
} catch (err) {
console.warn('Error processing date for newsletter:', { dateStr, title }, err);
}
}
});
}
if (newsletters.length === 0) {
console.error('No newsletters found in HTML. First 500 chars of response:', data.slice(0, 500));

View File

@@ -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 { z } from "zod";
@@ -8,6 +8,9 @@ export const newsletters = pgTable("newsletters", {
date: date("date").notNull(),
url: text("url").notNull(),
description: text("description"),
thumbnail: text("thumbnail"),
content: text("content"),
last_checked: timestamp("last_checked"),
});
export const insertNewsletterSchema = createInsertSchema(newsletters).pick({
@@ -15,7 +18,27 @@ export const insertNewsletterSchema = createInsertSchema(newsletters).pick({
date: true,
url: true,
description: true,
thumbnail: true,
content: true,
});
export type InsertNewsletter = z.infer<typeof insertNewsletterSchema>;
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;