Add web push notification system for user engagement and updates
Implement a web push notification system, including service worker integration, user subscription management, and an admin interface for sending broadcast messages. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: f585829f-898b-492f-82a5-11f4a76c87fb Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/ICRgny1 Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
8e7dc999f0
commit
f6478a7663
31
client/public/sw.js
Normal file
31
client/public/sw.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
let data = { title: "FOLX TV", body: "Neuer Inhalt verfügbar!", url: "/" };
|
||||||
|
try {
|
||||||
|
data = event.data.json();
|
||||||
|
} catch (e) {}
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, {
|
||||||
|
body: data.body,
|
||||||
|
icon: "/favicon.png",
|
||||||
|
badge: "/favicon.png",
|
||||||
|
data: { url: data.url || "/" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
let url = event.notification.data?.url || "/";
|
||||||
|
if (!url.startsWith("/") || url.startsWith("//")) url = "/";
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(self.location.origin) && "focus" in client) {
|
||||||
|
client.navigate(url);
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clients.openWindow(url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -19,6 +19,7 @@ import ImpressumPage from "@/pages/impressum";
|
|||||||
import DatenschutzPage from "@/pages/datenschutz";
|
import DatenschutzPage from "@/pages/datenschutz";
|
||||||
import KontaktPage from "@/pages/kontakt";
|
import KontaktPage from "@/pages/kontakt";
|
||||||
import AdminGalleryPage from "@/pages/admin-gallery";
|
import AdminGalleryPage from "@/pages/admin-gallery";
|
||||||
|
import AdminPushPage from "@/pages/admin-push";
|
||||||
import CookieConsent from "@/components/cookie-consent";
|
import CookieConsent from "@/components/cookie-consent";
|
||||||
|
|
||||||
function ScrollToTop() {
|
function ScrollToTop() {
|
||||||
@ -49,6 +50,7 @@ function Router() {
|
|||||||
<Route path="/datenschutz" component={DatenschutzPage} />
|
<Route path="/datenschutz" component={DatenschutzPage} />
|
||||||
<Route path="/kontakt" component={KontaktPage} />
|
<Route path="/kontakt" component={KontaktPage} />
|
||||||
<Route path="/admin/gallery" component={AdminGalleryPage} />
|
<Route path="/admin/gallery" component={AdminGalleryPage} />
|
||||||
|
<Route path="/admin/push" component={AdminPushPage} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Menu, X, Search } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import folxLogo from "@assets/folx_MT_poz_b_1772296729169.png";
|
import folxLogo from "@assets/folx_MT_poz_b_1772296729169.png";
|
||||||
|
import PushNotificationBell from "./push-notification-bell";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Start", href: "/" },
|
{ label: "Start", href: "/" },
|
||||||
@ -63,6 +64,7 @@ export default function Header() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<PushNotificationBell />
|
||||||
{searchOpen ? (
|
{searchOpen ? (
|
||||||
<form onSubmit={handleSearch} className="flex items-center gap-1" data-testid="form-header-search">
|
<form onSubmit={handleSearch} className="flex items-center gap-1" data-testid="form-header-search">
|
||||||
<input
|
<input
|
||||||
|
|||||||
126
client/src/components/push-notification-bell.tsx
Normal file
126
client/src/components/push-notification-bell.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Bell, BellRing } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string) {
|
||||||
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PushNotificationBell() {
|
||||||
|
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [supported, setSupported] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const checkSubscription = useCallback(async () => {
|
||||||
|
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
|
||||||
|
setSupported(true);
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.getRegistration("/sw.js");
|
||||||
|
if (reg) {
|
||||||
|
const sub = await reg.pushManager.getSubscription();
|
||||||
|
setIsSubscribed(!!sub);
|
||||||
|
} else {
|
||||||
|
setIsSubscribed(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsSubscribed(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkSubscription();
|
||||||
|
}, [checkSubscription]);
|
||||||
|
|
||||||
|
const subscribe = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const keyRes = await fetch("/api/push/vapid-key");
|
||||||
|
const { publicKey } = await keyRes.json();
|
||||||
|
if (!publicKey) throw new Error("VAPID key not configured");
|
||||||
|
|
||||||
|
const reg = await navigator.serviceWorker.register("/sw.js");
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
const sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
const subJson = sub.toJSON();
|
||||||
|
const res = await fetch("/api/push/subscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: subJson.endpoint,
|
||||||
|
keys: subJson.keys,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Subscription save failed");
|
||||||
|
|
||||||
|
setIsSubscribed(true);
|
||||||
|
toast({ title: "Benachrichtigungen aktiviert", description: "Sie werden über neue Inhalte informiert." });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (Notification.permission === "denied") {
|
||||||
|
toast({ title: "Benachrichtigungen blockiert", description: "Bitte erlauben Sie Benachrichtigungen in Ihren Browsereinstellungen.", variant: "destructive" });
|
||||||
|
} else {
|
||||||
|
toast({ title: "Fehler", description: "Benachrichtigungen konnten nicht aktiviert werden.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.getRegistration("/sw.js");
|
||||||
|
if (reg) {
|
||||||
|
const sub = await reg.pushManager.getSubscription();
|
||||||
|
if (sub) {
|
||||||
|
const res = await fetch("/api/push/unsubscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Unsubscribe failed");
|
||||||
|
await sub.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsSubscribed(false);
|
||||||
|
toast({ title: "Benachrichtigungen deaktiviert", description: "Sie erhalten keine weiteren Benachrichtigungen." });
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Fehler", description: "Abmeldung fehlgeschlagen.", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!supported) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 relative"
|
||||||
|
onClick={isSubscribed ? unsubscribe : subscribe}
|
||||||
|
disabled={isLoading}
|
||||||
|
title={isSubscribed ? "Benachrichtigungen deaktivieren" : "Benachrichtigungen aktivieren"}
|
||||||
|
data-testid="button-push-notifications"
|
||||||
|
>
|
||||||
|
{isSubscribed ? (
|
||||||
|
<BellRing className="w-4 h-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Bell className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
client/src/pages/admin-push.tsx
Normal file
122
client/src/pages/admin-push.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Send, Bell, Users, ArrowLeft } from "lucide-react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
|
||||||
|
export default function AdminPushPage() {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: countData } = useQuery<{ count: number }>({
|
||||||
|
queryKey: ["/api/push/count"],
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await apiRequest("POST", "/api/admin/push/send", { title, body, url: url || "/" });
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Push gesendet",
|
||||||
|
description: `Erfolgreich: ${data.sent}, Fehlgeschlagen: ${data.failed} von ${data.total} Abonnenten`,
|
||||||
|
});
|
||||||
|
setTitle("");
|
||||||
|
setBody("");
|
||||||
|
setUrl("");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: "Fehler", description: "Push-Nachricht konnte nicht gesendet werden.", variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="ghost" size="icon" data-testid="button-back-home">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Bell className="w-6 h-6 text-primary" />
|
||||||
|
<h1 className="text-2xl font-bold text-foreground" data-testid="text-admin-push-title">Push-Benachrichtigungen</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
Abonnenten
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold text-primary" data-testid="text-subscriber-count">
|
||||||
|
{countData?.count ?? 0}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">aktive Push-Abonnenten</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
Neue Benachrichtigung senden
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground mb-1 block">Titel</label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="z.B. Neuer Artikel auf FOLX TV"
|
||||||
|
data-testid="input-push-title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground mb-1 block">Nachricht</label>
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="z.B. Lesen Sie jetzt unseren neuesten Beitrag..."
|
||||||
|
rows={3}
|
||||||
|
data-testid="input-push-body"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground mb-1 block">Link (optional)</label>
|
||||||
|
<Input
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="z.B. /article/mein-artikel-slug"
|
||||||
|
data-testid="input-push-url"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Leer lassen für Startseite</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => sendMutation.mutate()}
|
||||||
|
disabled={!title.trim() || !body.trim() || sendMutation.isPending}
|
||||||
|
className="w-full"
|
||||||
|
data-testid="button-send-push"
|
||||||
|
>
|
||||||
|
{sendMutation.isPending ? "Wird gesendet..." : `An ${countData?.count ?? 0} Abonnenten senden`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
@ -42,6 +42,7 @@
|
|||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cloudinary": "^2.9.0",
|
"cloudinary": "^2.9.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -3897,6 +3898,15 @@
|
|||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.5.13",
|
"version": "8.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
|
||||||
|
|||||||
@ -44,6 +44,7 @@
|
|||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cloudinary": "^2.9.0",
|
"cloudinary": "^2.9.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@ -17,6 +17,7 @@ The official website for Folx Music Television (folx.tv). Dark-themed bento grid
|
|||||||
- Recipe widget + full /rezepte subpage (21 recipes across 5 regions: Österreich, Bayern, Schwaben/Baden, Südtirol/Alpen, Norddeutschland) with AI-generated images
|
- Recipe widget + full /rezepte subpage (21 recipes across 5 regions: Österreich, Bayern, Schwaben/Baden, Südtirol/Alpen, Norddeutschland) with AI-generated images
|
||||||
- Google News RSS widget (Volksmusik/Schlager news, 5 items, auto-rotate)
|
- Google News RSS widget (Volksmusik/Schlager news, 5 items, auto-rotate)
|
||||||
- Google AdSense integration (ca-pub-4465464714854276)
|
- Google AdSense integration (ca-pub-4465464714854276)
|
||||||
|
- Web Push Notifications (bell icon in header, admin panel at /admin/push)
|
||||||
- Article listing with featured carousel and category filtering
|
- Article listing with featured carousel and category filtering
|
||||||
- HTML content supports embedded iframes (bunny.net, YouTube, Facebook, Instagram, TikTok)
|
- HTML content supports embedded iframes (bunny.net, YouTube, Facebook, Instagram, TikTok)
|
||||||
- DOMPurify sanitization for safe HTML rendering
|
- DOMPurify sanitization for safe HTML rendering
|
||||||
@ -46,6 +47,11 @@ The official website for Folx Music Television (folx.tv). Dark-themed bento grid
|
|||||||
- `PATCH /api/articles/:id` - Update article
|
- `PATCH /api/articles/:id` - Update article
|
||||||
- `DELETE /api/articles/:id` - Delete article
|
- `DELETE /api/articles/:id` - Delete article
|
||||||
- `POST /api/upload` - Upload image file
|
- `POST /api/upload` - Upload image file
|
||||||
|
- `GET /api/push/vapid-key` - Get VAPID public key for push subscriptions
|
||||||
|
- `POST /api/push/subscribe` - Subscribe to push notifications
|
||||||
|
- `POST /api/push/unsubscribe` - Unsubscribe from push notifications
|
||||||
|
- `GET /api/push/count` - Get push subscriber count
|
||||||
|
- `POST /api/admin/push/send` - Send push notification to all subscribers
|
||||||
- `GET /api/gallery` - Shuffled gallery images from Cloudinary (with artist names from filenames + overrides)
|
- `GET /api/gallery` - Shuffled gallery images from Cloudinary (with artist names from filenames + overrides)
|
||||||
- `GET /api/gallery/focal-points` - Gallery image focal points (JSON)
|
- `GET /api/gallery/focal-points` - Gallery image focal points (JSON)
|
||||||
- `PUT /api/gallery/focal-points/:fileName` - Set focal point for gallery image
|
- `PUT /api/gallery/focal-points/:fileName` - Set focal point for gallery image
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import multer from "multer";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
|
import webpush from "web-push";
|
||||||
|
|
||||||
const apiCache = new Map<string, { data: any; timestamp: number }>();
|
const apiCache = new Map<string, { data: any; timestamp: number }>();
|
||||||
function getCached<T>(key: string, ttlMs: number): T | null {
|
function getCached<T>(key: string, ttlMs: number): T | null {
|
||||||
@ -495,6 +496,84 @@ export async function registerRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const vapidPublic = process.env.VAPID_PUBLIC_KEY || "";
|
||||||
|
const vapidPrivate = process.env.VAPID_PRIVATE_KEY || "";
|
||||||
|
if (vapidPublic && vapidPrivate) {
|
||||||
|
webpush.setVapidDetails("mailto:office@folx.tv", vapidPublic, vapidPrivate);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/push/vapid-key", (_req, res) => {
|
||||||
|
res.json({ publicKey: vapidPublic });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/push/subscribe", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { endpoint, keys } = req.body;
|
||||||
|
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||||
|
return res.status(400).json({ message: "Ungültige Subscription-Daten." });
|
||||||
|
}
|
||||||
|
await storage.savePushSubscription({ endpoint, p256dh: keys.p256dh, auth: keys.auth });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/push/unsubscribe", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { endpoint } = req.body;
|
||||||
|
if (!endpoint) return res.status(400).json({ message: "Endpoint fehlt." });
|
||||||
|
await storage.deletePushSubscription(endpoint);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/push/count", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const count = await storage.getPushSubscriptionCount();
|
||||||
|
res.json({ count });
|
||||||
|
} catch {
|
||||||
|
res.json({ count: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/push/send", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, body, url } = req.body;
|
||||||
|
if (!title || !body) return res.status(400).json({ message: "Titel und Nachricht sind erforderlich." });
|
||||||
|
if (typeof title !== "string" || title.length > 200) return res.status(400).json({ message: "Titel ungültig." });
|
||||||
|
if (typeof body !== "string" || body.length > 500) return res.status(400).json({ message: "Nachricht ungültig." });
|
||||||
|
let safeUrl = "/";
|
||||||
|
if (url && typeof url === "string" && url.startsWith("/") && !url.startsWith("//")) {
|
||||||
|
safeUrl = url;
|
||||||
|
}
|
||||||
|
const subs = await storage.getAllPushSubscriptions();
|
||||||
|
const payload = JSON.stringify({ title, body, url: safeUrl });
|
||||||
|
let sent = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const sub of subs) {
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
sent++;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||||
|
await storage.deletePushSubscription(sub.endpoint);
|
||||||
|
}
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[push] Sent to ${sent}, failed ${failed} of ${subs.length} subscribers`);
|
||||||
|
res.json({ ok: true, sent, failed, total: subs.length });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/gallery/artists", (_req, res) => {
|
app.get("/api/gallery/artists", (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const aPath = path.join(process.cwd(), "server/gallery-artist-overrides.json");
|
const aPath = path.join(process.cwd(), "server/gallery-artist-overrides.json");
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { type Article, type InsertArticle, articles, articleViews } from "@shared/schema";
|
import { type Article, type InsertArticle, articles, articleViews, type InsertPushSubscription, type PushSubscription, pushSubscriptions } from "@shared/schema";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, desc, sql, count, and } from "drizzle-orm";
|
import { eq, desc, sql, count, and } from "drizzle-orm";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
@ -15,6 +15,10 @@ export interface IStorage {
|
|||||||
updateArticle(id: number, article: Partial<InsertArticle>): Promise<Article | undefined>;
|
updateArticle(id: number, article: Partial<InsertArticle>): Promise<Article | undefined>;
|
||||||
incrementViews(id: number, ip: string): Promise<boolean>;
|
incrementViews(id: number, ip: string): Promise<boolean>;
|
||||||
deleteArticle(id: number): Promise<void>;
|
deleteArticle(id: number): Promise<void>;
|
||||||
|
savePushSubscription(sub: InsertPushSubscription): Promise<PushSubscription>;
|
||||||
|
deletePushSubscription(endpoint: string): Promise<void>;
|
||||||
|
getAllPushSubscriptions(): Promise<PushSubscription[]>;
|
||||||
|
getPushSubscriptionCount(): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DatabaseStorage implements IStorage {
|
export class DatabaseStorage implements IStorage {
|
||||||
@ -76,6 +80,29 @@ export class DatabaseStorage implements IStorage {
|
|||||||
async deleteArticle(id: number): Promise<void> {
|
async deleteArticle(id: number): Promise<void> {
|
||||||
await db.delete(articles).where(eq(articles.id, id));
|
await db.delete(articles).where(eq(articles.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async savePushSubscription(sub: InsertPushSubscription): Promise<PushSubscription> {
|
||||||
|
const [existing] = await db.select().from(pushSubscriptions).where(eq(pushSubscriptions.endpoint, sub.endpoint)).limit(1);
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db.update(pushSubscriptions).set(sub).where(eq(pushSubscriptions.endpoint, sub.endpoint)).returning();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
const [created] = await db.insert(pushSubscriptions).values(sub).returning();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePushSubscription(endpoint: string): Promise<void> {
|
||||||
|
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPushSubscriptions(): Promise<PushSubscription[]> {
|
||||||
|
return db.select().from(pushSubscriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPushSubscriptionCount(): Promise<number> {
|
||||||
|
const [result] = await db.select({ count: count() }).from(pushSubscriptions);
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storage = new DatabaseStorage();
|
export const storage = new DatabaseStorage();
|
||||||
|
|||||||
@ -63,3 +63,19 @@ export const insertUserSchema = createInsertSchema(users).pick({
|
|||||||
|
|
||||||
export type InsertUser = z.infer<typeof insertUserSchema>;
|
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
|
|
||||||
|
export const pushSubscriptions = pgTable("push_subscriptions", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
endpoint: text("endpoint").notNull().unique(),
|
||||||
|
p256dh: text("p256dh").notNull(),
|
||||||
|
auth: text("auth").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const insertPushSubscriptionSchema = createInsertSchema(pushSubscriptions).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InsertPushSubscription = z.infer<typeof insertPushSubscriptionSchema>;
|
||||||
|
export type PushSubscription = typeof pushSubscriptions.$inferSelect;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user