refresh redesign

This commit is contained in:
MDeeApp
2025-10-24 23:28:22 +02:00
parent c7cb02cf2d
commit 6ef62f069c
2 changed files with 372 additions and 21 deletions

View File

@@ -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 });
}

View File

@@ -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 = `
<div class="post-screenshot" data-screenshot="${escapeHtml(resolvedScreenshotPath)}" role="button" tabindex="0" aria-label="Screenshot anzeigen">
<img src="${escapeHtml(resolvedScreenshotPath)}" alt="Screenshot zum Beitrag" loading="lazy" />
<div class="post-screenshot" data-screenshot="${escapeHtml(versionedScreenshotPath)}" role="button" tabindex="0" aria-label="Screenshot anzeigen">
<img src="${escapeHtml(versionedScreenshotPath)}" alt="Screenshot zum Beitrag" loading="lazy" />
</div>
`;
@@ -3862,4 +4032,5 @@ loadProfile();
startProfilePolling();
fetchPosts();
checkAutoCheck();
startUpdatesStream();
applyAutoRefreshSettings();