Implement Server-Sent Events for real-time updates and enhance API route handling. Added loading state and last update indicator in the home page. Improved folder and post detail fetching logic in the admin page. Added webhook notification on file changes.
Some checks failed
Deploy / build-and-deploy (push) Failing after 2s
Some checks failed
Deploy / build-and-deploy (push) Failing after 2s
This commit is contained in:
@@ -3,6 +3,24 @@ const nextConfig = {
|
|||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
swcMinify: true,
|
swcMinify: true,
|
||||||
|
// Exclude SSE route from static generation
|
||||||
|
experimental: {
|
||||||
|
serverComponentsExternalPackages: ['chokidar']
|
||||||
|
},
|
||||||
|
// Handle API routes that shouldn't be statically generated
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/posts/stream',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'Cache-Control',
|
||||||
|
value: 'no-cache, no-store, must-revalidate',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
@@ -190,29 +190,43 @@ export default function ManagePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch folder details when currentNodes change
|
// Fetch folder details only when navigating to a new directory
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchDetails() {
|
async function fetchDetails() {
|
||||||
const details: Record<string, { created: string, items: number, size: number, error?: string }> = {};
|
const details: Record<string, { created: string, items: number, size: number, error?: string }> = {};
|
||||||
await Promise.all(currentNodes.filter(n => n.type === 'folder').map(async (folder: any) => {
|
const folders = currentNodes.filter(n => n.type === 'folder');
|
||||||
|
|
||||||
|
// Only fetch for folders that don't already have details
|
||||||
|
const foldersToFetch = folders.filter((folder: any) => !folderDetails[folder.path]);
|
||||||
|
|
||||||
|
if (foldersToFetch.length > 0) {
|
||||||
|
await Promise.all(foldersToFetch.map(async (folder: any) => {
|
||||||
details[folder.path] = await getFolderDetails(folder.path);
|
details[folder.path] = await getFolderDetails(folder.path);
|
||||||
}));
|
}));
|
||||||
setFolderDetails(details);
|
setFolderDetails(prev => ({ ...prev, ...details }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fetchDetails();
|
fetchDetails();
|
||||||
}, [currentNodes]);
|
}, [currentPath]); // Only trigger when path changes, not on every currentNodes change
|
||||||
|
|
||||||
// Fetch post sizes and creation dates when currentNodes change
|
// Fetch post sizes only when navigating to a new directory
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchSizes() {
|
async function fetchSizes() {
|
||||||
const sizes: Record<string, { size: number | null, created: string | null }> = {};
|
const sizes: Record<string, { size: number | null, created: string | null }> = {};
|
||||||
await Promise.all(currentNodes.filter(n => n.type === 'post').map(async (post: any) => {
|
const posts = currentNodes.filter(n => n.type === 'post');
|
||||||
|
|
||||||
|
// Only fetch for posts that don't already have sizes
|
||||||
|
const postsToFetch = posts.filter((post: any) => postSizes[post.slug] === undefined);
|
||||||
|
|
||||||
|
if (postsToFetch.length > 0) {
|
||||||
|
await Promise.all(postsToFetch.map(async (post: any) => {
|
||||||
sizes[post.slug] = await getPostSize(post.slug);
|
sizes[post.slug] = await getPostSize(post.slug);
|
||||||
}));
|
}));
|
||||||
setPostSizes(sizes);
|
setPostSizes(prev => ({ ...prev, ...sizes }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fetchSizes();
|
fetchSizes();
|
||||||
}, [currentNodes]);
|
}, [currentPath]); // Only trigger when path changes, not on every currentNodes change
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return null; // Will redirect in useEffect
|
return null; // Will redirect in useEffect
|
||||||
@@ -232,6 +246,16 @@ export default function ManagePage() {
|
|||||||
Back to Admin
|
Back to Admin
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={loadContent}
|
||||||
|
className="px-4 py-3 sm:py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-base font-medium"
|
||||||
|
title="Refresh content"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium"
|
className="px-4 py-3 sm:py-2 bg-red-600 text-white rounded hover:bg-red-700 text-base font-medium"
|
||||||
@@ -239,6 +263,7 @@ export default function ManagePage() {
|
|||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mobile-friendly breadcrumbs */}
|
{/* Mobile-friendly breadcrumbs */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4 sm:mb-6">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4 sm:mb-6">
|
||||||
|
|||||||
57
src/app/api/posts/stream/route.ts
Normal file
57
src/app/api/posts/stream/route.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { watchPosts, stopWatching } from '@/lib/markdown';
|
||||||
|
|
||||||
|
// Prevent static generation of this route
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// Store connected clients
|
||||||
|
const clients = new Set<ReadableStreamDefaultController>();
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
// Add this client to the set
|
||||||
|
clients.add(controller);
|
||||||
|
|
||||||
|
// Send initial connection message
|
||||||
|
controller.enqueue(`data: ${JSON.stringify({ type: 'connected', message: 'SSE connection established' })}\n\n`);
|
||||||
|
|
||||||
|
// Set up file watcher if not already set up
|
||||||
|
if (clients.size === 1) {
|
||||||
|
watchPosts(() => {
|
||||||
|
// Notify all connected clients about the update
|
||||||
|
const message = JSON.stringify({ type: 'update', timestamp: new Date().toISOString() });
|
||||||
|
clients.forEach(client => {
|
||||||
|
try {
|
||||||
|
client.enqueue(`data: ${message}\n\n`);
|
||||||
|
} catch (error) {
|
||||||
|
// Remove disconnected clients
|
||||||
|
clients.delete(client);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up when client disconnects
|
||||||
|
request.signal.addEventListener('abort', () => {
|
||||||
|
clients.delete(controller);
|
||||||
|
|
||||||
|
// Stop watching if no clients are connected
|
||||||
|
if (clients.size === 0) {
|
||||||
|
stopWatching();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
62
src/app/api/posts/webhook/route.ts
Normal file
62
src/app/api/posts/webhook/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// Prevent static generation of this route
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// Store connected clients for webhook notifications
|
||||||
|
const webhookClients = new Set<{ id: string; controller: ReadableStreamDefaultController }>();
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const clientId = searchParams.get('clientId') || Math.random().toString(36).substr(2, 9);
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
// Add this client to the set
|
||||||
|
webhookClients.add({ id: clientId, controller });
|
||||||
|
|
||||||
|
// Send initial connection message
|
||||||
|
controller.enqueue(`data: ${JSON.stringify({ type: 'connected', clientId, message: 'Webhook connection established' })}\n\n`);
|
||||||
|
|
||||||
|
// Clean up when client disconnects
|
||||||
|
request.signal.addEventListener('abort', () => {
|
||||||
|
webhookClients.delete({ id: clientId, controller });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook endpoint that can be called when files change
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { type = 'update', slug } = body;
|
||||||
|
|
||||||
|
// Notify all connected clients
|
||||||
|
const message = JSON.stringify({ type, slug, timestamp: new Date().toISOString() });
|
||||||
|
webhookClients.forEach(({ controller }) => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(`data: ${message}\n\n`);
|
||||||
|
} catch (error) {
|
||||||
|
// Remove disconnected clients
|
||||||
|
webhookClients.delete({ id: '', controller });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, clientsNotified: webhookClients.size });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Webhook error:', error);
|
||||||
|
return NextResponse.json({ error: 'Invalid webhook payload' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,26 +31,90 @@ export default function Home() {
|
|||||||
const [tree, setTree] = useState<Node[]>([]);
|
const [tree, setTree] = useState<Node[]>([]);
|
||||||
const [currentPath, setCurrentPath] = useState<string[]>([]);
|
const [currentPath, setCurrentPath] = useState<string[]>([]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||||
|
|
||||||
// Get blog owner from env
|
// Get blog owner from env
|
||||||
const blogOwner = process.env.NEXT_PUBLIC_BLOG_OWNER || 'Anonymous';
|
const blogOwner = process.env.NEXT_PUBLIC_BLOG_OWNER || 'Anonymous';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTree();
|
loadTree();
|
||||||
const interval = setInterval(loadTree, 500);
|
|
||||||
return () => clearInterval(interval);
|
// Set up Server-Sent Events for real-time updates (optional)
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
let fallbackInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const setupSSE = () => {
|
||||||
|
try {
|
||||||
|
eventSource = new EventSource('/api/posts/stream');
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'update') {
|
||||||
|
loadTree();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing SSE data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE connection error:', error);
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
// Fallback to minimal polling if SSE fails
|
||||||
|
fallbackInterval = setInterval(loadTree, 30000); // 30 seconds
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
console.log('SSE connection established');
|
||||||
|
// Clear any fallback interval if SSE is working
|
||||||
|
if (fallbackInterval) {
|
||||||
|
clearInterval(fallbackInterval);
|
||||||
|
fallbackInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to establish SSE connection:', error);
|
||||||
|
// Fallback to minimal polling if SSE is not supported
|
||||||
|
fallbackInterval = setInterval(loadTree, 30000); // 30 seconds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setupSSE();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
if (fallbackInterval) {
|
||||||
|
clearInterval(fallbackInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadTree = async () => {
|
const loadTree = async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
const response = await fetch('/api/posts');
|
const response = await fetch('/api/posts');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setTree(data);
|
setTree(data);
|
||||||
|
setLastUpdate(new Date());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Beiträge:', error);
|
console.error('Fehler beim Laden der Beiträge:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Manual refresh function
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadTree();
|
||||||
|
};
|
||||||
|
|
||||||
// Traverse the tree to the current path
|
// Traverse the tree to the current path
|
||||||
const getCurrentNodes = (): Node[] => {
|
const getCurrentNodes = (): Node[] => {
|
||||||
let nodes: Node[] = tree;
|
let nodes: Node[] = tree;
|
||||||
@@ -107,17 +171,41 @@ export default function Home() {
|
|||||||
{/* Mobile-first header section */}
|
{/* Mobile-first header section */}
|
||||||
<div className="mb-6 sm:mb-8 space-y-4 sm:space-y-0 sm:flex sm:flex-row sm:gap-4 sm:items-center sm:justify-between">
|
<div className="mb-6 sm:mb-8 space-y-4 sm:space-y-0 sm:flex sm:flex-row sm:gap-4 sm:items-center sm:justify-between">
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-center sm:text-left">{blogOwner}'s Blog</h1>
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-center sm:text-left">{blogOwner}'s Blog</h1>
|
||||||
<div className="w-full sm:w-auto">
|
<div className="w-full sm:w-auto flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
placeholder="Suche nach Titel, Tag oder Text..."
|
placeholder="Suche nach Titel, Tag oder Text..."
|
||||||
className="w-full sm:w-80 border border-gray-300 rounded-lg px-4 py-3 text-base focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="flex-1 sm:w-80 border border-gray-300 rounded-lg px-4 py-3 text-base focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="Refresh content"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Last update indicator */}
|
||||||
|
{lastUpdate && (
|
||||||
|
<div className="text-xs text-gray-500 text-center sm:text-left mb-4">
|
||||||
|
Last updated: {lastUpdate.toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search Results Section */}
|
{/* Search Results Section */}
|
||||||
{search.trim() && (
|
{search.trim() && (
|
||||||
<div className="mb-8 sm:mb-10">
|
<div className="mb-8 sm:mb-10">
|
||||||
|
|||||||
@@ -91,11 +91,60 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
|
|||||||
// Initial load
|
// Initial load
|
||||||
loadPost();
|
loadPost();
|
||||||
|
|
||||||
// Set up polling for changes
|
// Set up Server-Sent Events for real-time updates (optional)
|
||||||
const interval = setInterval(loadPost, 2000);
|
let eventSource: EventSource | null = null;
|
||||||
|
let fallbackInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
// Cleanup
|
const setupSSE = () => {
|
||||||
return () => clearInterval(interval);
|
try {
|
||||||
|
eventSource = new EventSource('/api/posts/stream');
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'update' && data.slug === slugPath) {
|
||||||
|
loadPost();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing SSE data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE connection error:', error);
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
// Fallback to minimal polling if SSE fails
|
||||||
|
fallbackInterval = setInterval(loadPost, 30000); // 30 seconds
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
console.log('SSE connection established');
|
||||||
|
// Clear any fallback interval if SSE is working
|
||||||
|
if (fallbackInterval) {
|
||||||
|
clearInterval(fallbackInterval);
|
||||||
|
fallbackInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to establish SSE connection:', error);
|
||||||
|
// Fallback to minimal polling if SSE is not supported
|
||||||
|
fallbackInterval = setInterval(loadPost, 30000); // 30 seconds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setupSSE();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
if (fallbackInterval) {
|
||||||
|
clearInterval(fallbackInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [slugPath]);
|
}, [slugPath]);
|
||||||
|
|
||||||
// Enhanced anchor scrolling logic
|
// Enhanced anchor scrolling logic
|
||||||
|
|||||||
@@ -235,6 +235,20 @@ function handleFileChange() {
|
|||||||
if (onChangeCallback) {
|
if (onChangeCallback) {
|
||||||
onChangeCallback();
|
onChangeCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also notify via webhook if available
|
||||||
|
try {
|
||||||
|
fetch('/api/posts/webhook', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'update', timestamp: new Date().toISOString() })
|
||||||
|
}).catch(error => {
|
||||||
|
// Webhook is optional, so we don't need to handle this as a critical error
|
||||||
|
console.debug('Webhook notification failed:', error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore webhook errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopWatching() {
|
export function stopWatching() {
|
||||||
|
|||||||
Reference in New Issue
Block a user