refresh redesign
This commit is contained in:
@@ -70,6 +70,87 @@ const dbPath = path.join(__dirname, 'data', 'tracker.db');
|
|||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
db.pragma('foreign_keys = ON');
|
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) {
|
function ensureColumn(table, column, definition) {
|
||||||
const info = db.prepare(`PRAGMA table_info(${table})`).all();
|
const info = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||||
if (!info.some((row) => row.name === column)) {
|
if (!info.some((row) => row.name === column)) {
|
||||||
@@ -833,7 +914,7 @@ db.prepare(`
|
|||||||
WHERE last_change IS NULL
|
WHERE last_change IS NULL
|
||||||
`).run();
|
`).run();
|
||||||
|
|
||||||
function touchPost(postId) {
|
function touchPost(postId, reason = null) {
|
||||||
if (!postId) {
|
if (!postId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -842,6 +923,7 @@ function touchPost(postId) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to update last_change for post ${postId}:`, error.message);
|
console.warn(`Failed to update last_change for post ${postId}:`, error.message);
|
||||||
}
|
}
|
||||||
|
queuePostBroadcast(postId, { reason: reason || 'touch' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeExistingPostUrls() {
|
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) => {
|
app.get('/api/bookmarks', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = listBookmarksStmt.all();
|
const rows = listBookmarksStmt.all();
|
||||||
@@ -1890,6 +2022,34 @@ app.delete('/api/bookmarks/:bookmarkId', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get all posts
|
// 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) => {
|
app.get('/api/posts', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const posts = db.prepare(`
|
const posts = db.prepare(`
|
||||||
@@ -1925,6 +2085,7 @@ app.get('/api/posts/by-url', (req, res) => {
|
|||||||
const alternates = collectPostAlternateUrls(post.url, [normalizedUrl]);
|
const alternates = collectPostAlternateUrls(post.url, [normalizedUrl]);
|
||||||
if (alternates.length) {
|
if (alternates.length) {
|
||||||
storePostUrls(post.id, post.url, alternates);
|
storePostUrls(post.id, post.url, alternates);
|
||||||
|
touchPost(post.id, 'alternate-urls');
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(mapPostRow(post));
|
res.json(mapPostRow(post));
|
||||||
@@ -1957,6 +2118,9 @@ app.post('/api/search-posts', (req, res) => {
|
|||||||
const alternateUrls = collectPostAlternateUrls(trackedPost.url, normalizedUrls);
|
const alternateUrls = collectPostAlternateUrls(trackedPost.url, normalizedUrls);
|
||||||
storePostUrls(trackedPost.id, trackedPost.url, alternateUrls);
|
storePostUrls(trackedPost.id, trackedPost.url, alternateUrls);
|
||||||
removeSearchSeenEntries([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 });
|
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);
|
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);
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
@@ -2121,9 +2287,10 @@ app.get('/api/posts/:postId/screenshot', (req, res) => {
|
|||||||
if (!post || !post.screenshot_path) {
|
if (!post || !post.screenshot_path) {
|
||||||
// Return placeholder image
|
// Return placeholder image
|
||||||
if (fs.existsSync(placeholderPath)) {
|
if (fs.existsSync(placeholderPath)) {
|
||||||
res.set('Cache-Control', 'public, max-age=86400');
|
res.set('Cache-Control', 'no-store');
|
||||||
return res.sendFile(placeholderPath);
|
return res.sendFile(placeholderPath);
|
||||||
}
|
}
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
return res.status(404).json({ error: 'Screenshot not found' });
|
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)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
// Return placeholder image
|
// Return placeholder image
|
||||||
if (fs.existsSync(placeholderPath)) {
|
if (fs.existsSync(placeholderPath)) {
|
||||||
res.set('Cache-Control', 'public, max-age=86400');
|
res.set('Cache-Control', 'no-store');
|
||||||
return res.sendFile(placeholderPath);
|
return res.sendFile(placeholderPath);
|
||||||
}
|
}
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
return res.status(404).json({ error: 'Screenshot not found' });
|
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())) {
|
if (normalizedPostText && (!existingByHash.post_text || !existingByHash.post_text.trim())) {
|
||||||
updatePostTextColumnsStmt.run(normalizedPostText, postTextHash, existingByHash.id);
|
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);
|
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);
|
storePostUrls(id, normalizedUrl, alternateUrls);
|
||||||
removeSearchSeenEntries([normalizedUrl, ...alternateUrls]);
|
removeSearchSeenEntries([normalizedUrl, ...alternateUrls]);
|
||||||
|
|
||||||
res.json(mapPostRow(post));
|
const formattedPost = mapPostRow(post);
|
||||||
|
res.json(formattedPost);
|
||||||
|
broadcastPostChange(formattedPost, { reason: 'created' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message.includes('UNIQUE constraint failed')) {
|
if (error.message.includes('UNIQUE constraint failed')) {
|
||||||
res.status(409).json({ error: 'Post with this URL already exists' });
|
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));
|
removeSearchSeenEntries(Array.from(cleanupUrls));
|
||||||
|
|
||||||
res.json(mapPostRow(updatedPost));
|
const formattedPost = mapPostRow(updatedPost);
|
||||||
|
res.json(formattedPost);
|
||||||
|
broadcastPostChange(formattedPost, { reason: 'updated' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
@@ -2465,7 +2637,7 @@ app.post('/api/posts/:postId/check', (req, res) => {
|
|||||||
didChange = true;
|
didChange = true;
|
||||||
recalcCheckedCount(postId);
|
recalcCheckedCount(postId);
|
||||||
if (didChange) {
|
if (didChange) {
|
||||||
touchPost(postId);
|
touchPost(postId, 'profile-status-update');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
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]);
|
removeSearchSeenEntries([post.url, ...alternateUrls]);
|
||||||
|
|
||||||
const storedAlternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url);
|
const storedAlternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url);
|
||||||
|
if (alternateUrls.length) {
|
||||||
|
touchPost(post.id, 'alternate-urls');
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
primary_url: post.url,
|
primary_url: post.url,
|
||||||
@@ -2587,7 +2762,7 @@ app.post('/api/check-by-url', (req, res) => {
|
|||||||
didChange = true;
|
didChange = true;
|
||||||
recalcCheckedCount(post.id);
|
recalcCheckedCount(post.id);
|
||||||
if (didChange) {
|
if (didChange) {
|
||||||
touchPost(post.id);
|
touchPost(post.id, 'check-by-url');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id);
|
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);
|
recalcCheckedCount(postId);
|
||||||
if (didChange) {
|
if (didChange) {
|
||||||
touchPost(postId);
|
touchPost(postId, 'profile-status-update');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
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);
|
const contentKey = extractFacebookContentKey(normalizedUrl);
|
||||||
// Update URL
|
// 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 = [];
|
const alternateCandidates = [];
|
||||||
if (existingPost.url && existingPost.url !== normalizedUrl) {
|
if (existingPost.url && existingPost.url !== normalizedUrl) {
|
||||||
@@ -2720,15 +2895,19 @@ app.patch('/api/posts/:postId', (req, res) => {
|
|||||||
const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateCandidates);
|
const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateCandidates);
|
||||||
storePostUrls(postId, normalizedUrl, alternateUrls);
|
storePostUrls(postId, normalizedUrl, alternateUrls);
|
||||||
removeSearchSeenEntries([normalizedUrl, ...alternateUrls]);
|
removeSearchSeenEntries([normalizedUrl, ...alternateUrls]);
|
||||||
|
queuePostBroadcast(postId, { reason: 'url-updated' });
|
||||||
return res.json({ success: true, url: normalizedUrl });
|
return res.json({ success: true, url: normalizedUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_successful !== undefined) {
|
if (is_successful !== undefined) {
|
||||||
const successValue = is_successful ? 1 : 0;
|
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);
|
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' });
|
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 });
|
res.json({ success: true });
|
||||||
|
broadcastPostDeletion(postId, { reason: 'deleted' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
187
web/app.js
187
web/app.js
@@ -34,6 +34,11 @@ let currentProfile = 1;
|
|||||||
let currentTab = 'pending';
|
let currentTab = 'pending';
|
||||||
let posts = [];
|
let posts = [];
|
||||||
let profilePollTimer = null;
|
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 MAX_PROFILES = 5;
|
||||||
const PROFILE_NAMES = {
|
const PROFILE_NAMES = {
|
||||||
@@ -57,6 +62,153 @@ function apiFetch(url, options = {}) {
|
|||||||
return fetch(url, config);
|
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 screenshotModal = document.getElementById('screenshotModal');
|
||||||
const screenshotModalContent = document.getElementById('screenshotModalContent');
|
const screenshotModalContent = document.getElementById('screenshotModalContent');
|
||||||
const screenshotModalImage = document.getElementById('screenshotModalImage');
|
const screenshotModalImage = document.getElementById('screenshotModalImage');
|
||||||
@@ -131,7 +283,7 @@ function initializeFocusParams() {
|
|||||||
|
|
||||||
let autoRefreshTimer = null;
|
let autoRefreshTimer = null;
|
||||||
let autoRefreshSettings = {
|
let autoRefreshSettings = {
|
||||||
enabled: true,
|
enabled: false,
|
||||||
interval: 30000
|
interval: 30000
|
||||||
};
|
};
|
||||||
let sortMode = DEFAULT_SORT_SETTINGS.mode;
|
let sortMode = DEFAULT_SORT_SETTINGS.mode;
|
||||||
@@ -2106,10 +2258,22 @@ function applyAutoRefreshSettings() {
|
|||||||
autoRefreshTimer = null;
|
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) {
|
if (!autoRefreshSettings.enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updatesStreamHealthy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
autoRefreshTimer = setInterval(() => {
|
autoRefreshTimer = setInterval(() => {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
return;
|
return;
|
||||||
@@ -2485,12 +2649,12 @@ if (manualPostResetButton) {
|
|||||||
if (autoRefreshToggle) {
|
if (autoRefreshToggle) {
|
||||||
autoRefreshToggle.addEventListener('change', () => {
|
autoRefreshToggle.addEventListener('change', () => {
|
||||||
autoRefreshSettings.enabled = !!autoRefreshToggle.checked;
|
autoRefreshSettings.enabled = !!autoRefreshToggle.checked;
|
||||||
if (autoRefreshIntervalSelect) {
|
|
||||||
autoRefreshIntervalSelect.disabled = !autoRefreshSettings.enabled;
|
|
||||||
}
|
|
||||||
saveAutoRefreshSettings();
|
saveAutoRefreshSettings();
|
||||||
applyAutoRefreshSettings();
|
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 });
|
fetchPosts({ showLoader: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2578,6 +2742,7 @@ async function fetchPosts({ showLoader = true } = {}) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
posts = Array.isArray(data) ? data : [];
|
posts = Array.isArray(data) ? data : [];
|
||||||
await normalizeLoadedPostUrls();
|
await normalizeLoadedPostUrls();
|
||||||
|
sortPostsByCreatedAt();
|
||||||
renderPosts();
|
renderPosts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (showLoader) {
|
if (showLoader) {
|
||||||
@@ -3068,15 +3233,20 @@ function createPostCard(post, status, meta = {}) {
|
|||||||
const createdDate = formatDateTime(post.created_at) || '—';
|
const createdDate = formatDateTime(post.created_at) || '—';
|
||||||
const lastChangeDate = formatDateTime(post.last_change || 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.startsWith('http')
|
||||||
? post.screenshot_path
|
? post.screenshot_path
|
||||||
: `${API_URL.replace(/\/api$/, '')}${post.screenshot_path.startsWith('/') ? '' : '/'}${post.screenshot_path}`)
|
: `${API_URL.replace(/\/api$/, '')}${post.screenshot_path.startsWith('/') ? '' : '/'}${post.screenshot_path}`)
|
||||||
: `${API_URL}/posts/${post.id}/screenshot`;
|
: `${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 = `
|
const screenshotHtml = `
|
||||||
<div class="post-screenshot" data-screenshot="${escapeHtml(resolvedScreenshotPath)}" role="button" tabindex="0" aria-label="Screenshot anzeigen">
|
<div class="post-screenshot" data-screenshot="${escapeHtml(versionedScreenshotPath)}" role="button" tabindex="0" aria-label="Screenshot anzeigen">
|
||||||
<img src="${escapeHtml(resolvedScreenshotPath)}" alt="Screenshot zum Beitrag" loading="lazy" />
|
<img src="${escapeHtml(versionedScreenshotPath)}" alt="Screenshot zum Beitrag" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -3862,4 +4032,5 @@ loadProfile();
|
|||||||
startProfilePolling();
|
startProfilePolling();
|
||||||
fetchPosts();
|
fetchPosts();
|
||||||
checkAutoCheck();
|
checkAutoCheck();
|
||||||
|
startUpdatesStream();
|
||||||
applyAutoRefreshSettings();
|
applyAutoRefreshSettings();
|
||||||
|
|||||||
Reference in New Issue
Block a user