From 6ef62f069cc7dc0b0778b66d3b5324b57551a907 Mon Sep 17 00:00:00 2001 From: MDeeApp <6595194+MDeeApp@users.noreply.github.com> Date: Fri, 24 Oct 2025 23:28:22 +0200 Subject: [PATCH] refresh redesign --- backend/server.js | 206 +++++++++++++++++++++++++++++++++++++++++++--- web/app.js | 187 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 372 insertions(+), 21 deletions(-) diff --git a/backend/server.js b/backend/server.js index 4148dc5..f11ca25 100644 --- a/backend/server.js +++ b/backend/server.js @@ -70,6 +70,87 @@ const dbPath = path.join(__dirname, 'data', 'tracker.db'); const db = new Database(dbPath); db.pragma('foreign_keys = ON'); +const SSE_RETRY_INTERVAL_MS = 5000; +const SSE_HEARTBEAT_INTERVAL_MS = 30000; +const sseClients = new Map(); +let nextSseClientId = 1; + +function scheduleAsync(fn) { + if (typeof setImmediate === 'function') { + setImmediate(fn); + } else { + setTimeout(fn, 0); + } +} + +function removeSseClient(clientId) { + const client = sseClients.get(clientId); + if (!client) { + return; + } + sseClients.delete(clientId); + if (client.heartbeat) { + clearInterval(client.heartbeat); + } +} + +function addSseClient(res) { + const clientId = nextSseClientId++; + const client = { + id: clientId, + res, + heartbeat: setInterval(() => { + if (res.writableEnded) { + removeSseClient(clientId); + return; + } + try { + res.write('event: heartbeat\ndata: {}\n\n'); + } catch (error) { + removeSseClient(clientId); + } + }, SSE_HEARTBEAT_INTERVAL_MS) + }; + sseClients.set(clientId, client); + return client; +} + +function broadcastSseEvent(payload) { + if (!payload) { + return; + } + + let serialized; + try { + serialized = JSON.stringify(payload); + } catch (error) { + console.warn('Failed to serialize SSE payload:', error.message); + return; + } + + const message = `data: ${serialized}\n\n`; + + for (const [clientId, client] of sseClients.entries()) { + const target = client && client.res; + if (!target || target.writableEnded) { + removeSseClient(clientId); + continue; + } + try { + target.write(message); + } catch (error) { + removeSseClient(clientId); + } + } +} + +function queuePostBroadcast(postId, options = {}) { + if (!postId) { + return; + } + scheduleAsync(() => broadcastPostChangeById(postId, options)); +} + function ensureColumn(table, column, definition) { const info = db.prepare(`PRAGMA table_info(${table})`).all(); if (!info.some((row) => row.name === column)) { @@ -833,7 +914,7 @@ db.prepare(` WHERE last_change IS NULL `).run(); -function touchPost(postId) { +function touchPost(postId, reason = null) { if (!postId) { return; } @@ -842,6 +923,7 @@ function touchPost(postId) { } catch (error) { console.warn(`Failed to update last_change for post ${postId}:`, error.message); } + queuePostBroadcast(postId, { reason: reason || 'touch' }); } function normalizeExistingPostUrls() { @@ -1816,6 +1898,56 @@ function mapPostRow(post) { }; } +function broadcastPostChange(post, options = {}) { + if (!post || !post.id) { + return; + } + + const payload = { + type: 'post-upsert', + post + }; + + if (options && options.reason) { + payload.reason = options.reason; + } + + broadcastSseEvent(payload); +} + +function broadcastPostChangeById(postId, options = {}) { + if (!postId) { + return; + } + try { + const row = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + if (!row) { + return; + } + const postPayload = mapPostRow(row); + broadcastPostChange(postPayload, options); + } catch (error) { + console.warn(`Failed to broadcast post ${postId}:`, error.message); + } +} + +function broadcastPostDeletion(postId, options = {}) { + if (!postId) { + return; + } + + const payload = { + type: 'post-deleted', + postId + }; + + if (options && options.reason) { + payload.reason = options.reason; + } + + broadcastSseEvent(payload); +} + app.get('/api/bookmarks', (req, res) => { try { const rows = listBookmarksStmt.all(); @@ -1890,6 +2022,34 @@ app.delete('/api/bookmarks/:bookmarkId', (req, res) => { }); // Get all posts +app.get('/api/events', (req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } else { + res.write('\n'); + } + + res.write(`retry: ${SSE_RETRY_INTERVAL_MS}\n\n`); + + const client = addSseClient(res); + const initialPayload = { + type: 'connected', + clientId: client.id + }; + res.write(`data: ${JSON.stringify(initialPayload)}\n\n`); + + const cleanup = () => { + removeSseClient(client.id); + }; + + req.on('close', cleanup); + res.on('close', cleanup); +}); + app.get('/api/posts', (req, res) => { try { const posts = db.prepare(` @@ -1925,6 +2085,7 @@ app.get('/api/posts/by-url', (req, res) => { const alternates = collectPostAlternateUrls(post.url, [normalizedUrl]); if (alternates.length) { storePostUrls(post.id, post.url, alternates); + touchPost(post.id, 'alternate-urls'); } res.json(mapPostRow(post)); @@ -1957,6 +2118,9 @@ app.post('/api/search-posts', (req, res) => { const alternateUrls = collectPostAlternateUrls(trackedPost.url, normalizedUrls); storePostUrls(trackedPost.id, trackedPost.url, alternateUrls); removeSearchSeenEntries([trackedPost.url, ...alternateUrls]); + if (alternateUrls.length) { + touchPost(trackedPost.id, 'alternate-urls'); + } return res.json({ seen_count: 0, should_hide: false, tracked: true }); } @@ -2104,7 +2268,9 @@ app.post('/api/posts/:postId/screenshot', (req, res) => { db.prepare('UPDATE posts SET screenshot_path = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(fileName, postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); - res.json(mapPostRow(updatedPost)); + const formattedPost = mapPostRow(updatedPost); + res.json(formattedPost); + broadcastPostChange(formattedPost, { reason: 'screenshot-updated' }); } catch (error) { res.status(500).json({ error: error.message }); } @@ -2121,9 +2287,10 @@ app.get('/api/posts/:postId/screenshot', (req, res) => { if (!post || !post.screenshot_path) { // Return placeholder image if (fs.existsSync(placeholderPath)) { - res.set('Cache-Control', 'public, max-age=86400'); + res.set('Cache-Control', 'no-store'); return res.sendFile(placeholderPath); } + res.set('Cache-Control', 'no-store'); return res.status(404).json({ error: 'Screenshot not found' }); } @@ -2131,9 +2298,10 @@ app.get('/api/posts/:postId/screenshot', (req, res) => { if (!fs.existsSync(filePath)) { // Return placeholder image if (fs.existsSync(placeholderPath)) { - res.set('Cache-Control', 'public, max-age=86400'); + res.set('Cache-Control', 'no-store'); return res.sendFile(placeholderPath); } + res.set('Cache-Control', 'no-store'); return res.status(404).json({ error: 'Screenshot not found' }); } @@ -2190,7 +2358,7 @@ app.post('/api/posts', (req, res) => { if (normalizedPostText && (!existingByHash.post_text || !existingByHash.post_text.trim())) { updatePostTextColumnsStmt.run(normalizedPostText, postTextHash, existingByHash.id); - touchPost(existingByHash.id); + touchPost(existingByHash.id, 'post-text-normalized'); existingByHash = db.prepare('SELECT * FROM posts WHERE id = ?').get(existingByHash.id); } @@ -2250,7 +2418,9 @@ app.post('/api/posts', (req, res) => { storePostUrls(id, normalizedUrl, alternateUrls); removeSearchSeenEntries([normalizedUrl, ...alternateUrls]); - res.json(mapPostRow(post)); + const formattedPost = mapPostRow(post); + res.json(formattedPost); + broadcastPostChange(formattedPost, { reason: 'created' }); } catch (error) { if (error.message.includes('UNIQUE constraint failed')) { res.status(409).json({ error: 'Post with this URL already exists' }); @@ -2385,7 +2555,9 @@ app.put('/api/posts/:postId', (req, res) => { } removeSearchSeenEntries(Array.from(cleanupUrls)); - res.json(mapPostRow(updatedPost)); + const formattedPost = mapPostRow(updatedPost); + res.json(formattedPost); + broadcastPostChange(formattedPost, { reason: 'updated' }); } catch (error) { res.status(500).json({ error: error.message }); } @@ -2465,7 +2637,7 @@ app.post('/api/posts/:postId/check', (req, res) => { didChange = true; recalcCheckedCount(postId); if (didChange) { - touchPost(postId); + touchPost(postId, 'profile-status-update'); } const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); @@ -2492,6 +2664,9 @@ app.post('/api/posts/:postId/urls', (req, res) => { removeSearchSeenEntries([post.url, ...alternateUrls]); const storedAlternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url); + if (alternateUrls.length) { + touchPost(post.id, 'alternate-urls'); + } res.json({ success: true, primary_url: post.url, @@ -2587,7 +2762,7 @@ app.post('/api/check-by-url', (req, res) => { didChange = true; recalcCheckedCount(post.id); if (didChange) { - touchPost(post.id); + touchPost(post.id, 'check-by-url'); } const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id); @@ -2674,7 +2849,7 @@ app.post('/api/posts/:postId/profile-status', (req, res) => { recalcCheckedCount(postId); if (didChange) { - touchPost(postId); + touchPost(postId, 'profile-status-update'); } const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); @@ -2710,7 +2885,7 @@ app.patch('/api/posts/:postId', (req, res) => { const contentKey = extractFacebookContentKey(normalizedUrl); // Update URL - db.prepare('UPDATE posts SET url = ?, content_key = ? WHERE id = ?').run(normalizedUrl, contentKey || null, postId); + db.prepare('UPDATE posts SET url = ?, content_key = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(normalizedUrl, contentKey || null, postId); const alternateCandidates = []; if (existingPost.url && existingPost.url !== normalizedUrl) { @@ -2720,15 +2895,19 @@ app.patch('/api/posts/:postId', (req, res) => { const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateCandidates); storePostUrls(postId, normalizedUrl, alternateUrls); removeSearchSeenEntries([normalizedUrl, ...alternateUrls]); + queuePostBroadcast(postId, { reason: 'url-updated' }); return res.json({ success: true, url: normalizedUrl }); } if (is_successful !== undefined) { const successValue = is_successful ? 1 : 0; - db.prepare('UPDATE posts SET is_successful = ? WHERE id = ?').run(successValue, postId); + db.prepare('UPDATE posts SET is_successful = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(successValue, postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); - return res.json(mapPostRow(updatedPost)); + const formattedPost = mapPostRow(updatedPost); + res.json(formattedPost); + broadcastPostChange(formattedPost, { reason: 'success-flag' }); + return; } return res.status(400).json({ error: 'No valid update parameter provided' }); @@ -2763,6 +2942,7 @@ app.delete('/api/posts/:postId', (req, res) => { } res.json({ success: true }); + broadcastPostDeletion(postId, { reason: 'deleted' }); } catch (error) { res.status(500).json({ error: error.message }); } diff --git a/web/app.js b/web/app.js index 204c647..c46f509 100644 --- a/web/app.js +++ b/web/app.js @@ -34,6 +34,11 @@ let currentProfile = 1; let currentTab = 'pending'; let posts = []; let profilePollTimer = null; +const UPDATES_RECONNECT_DELAY = 5000; +let updatesEventSource = null; +let updatesReconnectTimer = null; +let updatesStreamHealthy = false; +let updatesShouldResyncOnConnect = false; const MAX_PROFILES = 5; const PROFILE_NAMES = { @@ -57,6 +62,153 @@ function apiFetch(url, options = {}) { return fetch(url, config); } +function sortPostsByCreatedAt() { + posts.sort((a, b) => toTimestamp(b.created_at, 0) - toTimestamp(a.created_at, 0)); +} + +function applyPostUpdateFromStream(post) { + if (!post || !post.id) { + return; + } + + const index = posts.findIndex((item) => item.id === post.id); + if (index !== -1) { + posts[index] = post; + } else { + posts.push(post); + } + + sortPostsByCreatedAt(); + + if (manualPostMode === 'edit' && manualPostEditingId === post.id) { + populateManualPostForm(post); + } + + renderPosts(); +} + +function removePostFromCache(postId) { + if (!postId) { + return; + } + + const index = posts.findIndex((item) => item.id === postId); + if (index === -1) { + return; + } + + posts.splice(index, 1); + + if ( + manualPostMode === 'edit' + && manualPostEditingId === postId + && manualPostModal + && manualPostModal.classList.contains('open') + ) { + closeManualPostModal(); + } + + renderPosts(); +} + +function handleBackendEvent(eventPayload) { + if (!eventPayload || typeof eventPayload !== 'object') { + return; + } + + switch (eventPayload.type) { + case 'post-upsert': + if (eventPayload.post) { + applyPostUpdateFromStream(eventPayload.post); + } + break; + case 'post-deleted': + if (eventPayload.postId) { + removePostFromCache(eventPayload.postId); + } + break; + case 'connected': + case 'heartbeat': + default: + break; + } +} + +function scheduleUpdatesReconnect() { + if (updatesReconnectTimer) { + return; + } + updatesReconnectTimer = setTimeout(() => { + updatesReconnectTimer = null; + startUpdatesStream(); + }, UPDATES_RECONNECT_DELAY); +} + +function startUpdatesStream() { + if (typeof EventSource === 'undefined') { + console.warn('EventSource wird von diesem Browser nicht unterstützt. Fallback auf Polling.'); + return; + } + + if (updatesEventSource) { + return; + } + + const eventsUrl = `${API_URL}/events`; + let eventSource; + + try { + eventSource = new EventSource(eventsUrl, { withCredentials: true }); + } catch (error) { + console.warn('Konnte Update-Stream nicht starten:', error); + scheduleUpdatesReconnect(); + return; + } + + updatesEventSource = eventSource; + + eventSource.addEventListener('open', () => { + updatesStreamHealthy = true; + if (updatesReconnectTimer) { + clearTimeout(updatesReconnectTimer); + updatesReconnectTimer = null; + } + if (updatesShouldResyncOnConnect) { + updatesShouldResyncOnConnect = false; + fetchPosts({ showLoader: false }); + } + applyAutoRefreshSettings(); + }); + + eventSource.addEventListener('message', (event) => { + if (!event || typeof event.data !== 'string' || !event.data.trim()) { + return; + } + let payload; + try { + payload = JSON.parse(event.data); + } catch (error) { + console.warn('Ungültige Daten vom Update-Stream erhalten:', error); + return; + } + handleBackendEvent(payload); + }); + + eventSource.addEventListener('error', () => { + if (updatesEventSource) { + updatesEventSource.close(); + updatesEventSource = null; + } + if (!updatesShouldResyncOnConnect) { + updatesShouldResyncOnConnect = true; + } + updatesStreamHealthy = false; + applyAutoRefreshSettings(); + fetchPosts({ showLoader: false }); + scheduleUpdatesReconnect(); + }); +} + const screenshotModal = document.getElementById('screenshotModal'); const screenshotModalContent = document.getElementById('screenshotModalContent'); const screenshotModalImage = document.getElementById('screenshotModalImage'); @@ -131,7 +283,7 @@ function initializeFocusParams() { let autoRefreshTimer = null; let autoRefreshSettings = { - enabled: true, + enabled: false, interval: 30000 }; let sortMode = DEFAULT_SORT_SETTINGS.mode; @@ -2106,10 +2258,22 @@ function applyAutoRefreshSettings() { autoRefreshTimer = null; } + if (autoRefreshIntervalSelect) { + const disabled = !autoRefreshSettings.enabled || updatesStreamHealthy; + autoRefreshIntervalSelect.disabled = disabled; + autoRefreshIntervalSelect.title = updatesStreamHealthy + ? 'Live-Updates sind aktiv; das Intervall wird aktuell nicht verwendet.' + : ''; + } + if (!autoRefreshSettings.enabled) { return; } + if (updatesStreamHealthy) { + return; + } + autoRefreshTimer = setInterval(() => { if (document.hidden) { return; @@ -2485,12 +2649,12 @@ if (manualPostResetButton) { if (autoRefreshToggle) { autoRefreshToggle.addEventListener('change', () => { autoRefreshSettings.enabled = !!autoRefreshToggle.checked; - if (autoRefreshIntervalSelect) { - autoRefreshIntervalSelect.disabled = !autoRefreshSettings.enabled; - } saveAutoRefreshSettings(); applyAutoRefreshSettings(); - if (autoRefreshSettings.enabled) { + if (autoRefreshSettings.enabled && updatesStreamHealthy) { + console.info('Live-Updates sind aktiv; automatisches Refresh bleibt pausiert.'); + } + if (autoRefreshSettings.enabled && !updatesStreamHealthy) { fetchPosts({ showLoader: false }); } }); @@ -2578,6 +2742,7 @@ async function fetchPosts({ showLoader = true } = {}) { const data = await response.json(); posts = Array.isArray(data) ? data : []; await normalizeLoadedPostUrls(); + sortPostsByCreatedAt(); renderPosts(); } catch (error) { if (showLoader) { @@ -3068,15 +3233,20 @@ function createPostCard(post, status, meta = {}) { const createdDate = formatDateTime(post.created_at) || '—'; const lastChangeDate = formatDateTime(post.last_change || post.created_at) || '—'; - const resolvedScreenshotPath = post.screenshot_path + const baseScreenshotPath = post.screenshot_path ? (post.screenshot_path.startsWith('http') ? post.screenshot_path : `${API_URL.replace(/\/api$/, '')}${post.screenshot_path.startsWith('/') ? '' : '/'}${post.screenshot_path}`) : `${API_URL}/posts/${post.id}/screenshot`; + const screenshotVersion = post.last_change || post.updated_at || post.created_at || ''; + const versionedScreenshotPath = screenshotVersion + ? `${baseScreenshotPath}${baseScreenshotPath.includes('?') ? '&' : '?'}v=${encodeURIComponent(screenshotVersion)}` + : baseScreenshotPath; + const resolvedScreenshotPath = versionedScreenshotPath; const screenshotHtml = ` -
- Screenshot zum Beitrag +
+ Screenshot zum Beitrag
`; @@ -3862,4 +4032,5 @@ loadProfile(); startProfilePolling(); fetchPosts(); checkAutoCheck(); +startUpdatesStream(); applyAutoRefreshSettings();