refresh redesign
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
187
web/app.js
187
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 = `
|
||||
<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();
|
||||
|
||||
Reference in New Issue
Block a user