aktueller stand
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,32 @@ function isOnReelsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maybeRedirectPageReelsToMain() {
|
||||||
|
try {
|
||||||
|
const { location } = window;
|
||||||
|
const pathname = location && location.pathname;
|
||||||
|
if (typeof pathname !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const match = pathname.match(/^\/([^/]+)\/reels\/?$/i);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const pageSlug = match[1];
|
||||||
|
if (!pageSlug) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const targetUrl = `${location.origin}/${pageSlug}/`;
|
||||||
|
if (location.href === targetUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
location.replace(targetUrl);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let debugLoggingEnabled = false;
|
let debugLoggingEnabled = false;
|
||||||
|
|
||||||
const originalConsoleLog = console.log.bind(console);
|
const originalConsoleLog = console.log.bind(console);
|
||||||
@@ -705,6 +731,140 @@ function expandPhotoUrlHostVariants(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchPostByUrl(url) {
|
||||||
|
const normalizedUrl = normalizeFacebookPostUrl(url);
|
||||||
|
if (!normalizedUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const response = await backendFetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(normalizedUrl)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data && data.id ? data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPostById(postId) {
|
||||||
|
if (!postId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await backendFetch(`${API_URL}/posts`);
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const posts = await response.json();
|
||||||
|
if (!Array.isArray(posts)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return posts.find(post => post && post.id === postId) || null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSimilarityPayload(postElement) {
|
||||||
|
let postText = null;
|
||||||
|
try {
|
||||||
|
postText = extractPostText(postElement) || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[FB Tracker] Failed to extract post text for similarity:', error);
|
||||||
|
}
|
||||||
|
const imageInfo = await getFirstPostImageInfo(postElement);
|
||||||
|
return {
|
||||||
|
postText,
|
||||||
|
firstImageHash: imageInfo.hash,
|
||||||
|
firstImageUrl: imageInfo.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findSimilarPost({ url, postText, firstImageHash }) {
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!postText && !firstImageHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
url,
|
||||||
|
post_text: postText || null,
|
||||||
|
first_image_hash: firstImageHash || null
|
||||||
|
};
|
||||||
|
const response = await backendFetch(`${API_URL}/posts/similar`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data && data.match ? data : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[FB Tracker] Similarity check failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenInline(text, maxLength = 64) {
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `${text.slice(0, maxLength - 3)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSimilarityLabel(similarity) {
|
||||||
|
if (!similarity || !similarity.match) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const match = similarity.match;
|
||||||
|
const base = match.title || match.created_by_name || match.url || 'Beitrag';
|
||||||
|
const details = [];
|
||||||
|
if (similarity.similarity && typeof similarity.similarity.text === 'number') {
|
||||||
|
details.push(`Text ${Math.round(similarity.similarity.text * 100)}%`);
|
||||||
|
}
|
||||||
|
if (similarity.similarity && typeof similarity.similarity.image_distance === 'number') {
|
||||||
|
details.push(`Bild Δ${similarity.similarity.image_distance}`);
|
||||||
|
}
|
||||||
|
const detailText = details.length ? ` (${details.join(', ')})` : '';
|
||||||
|
return `Ähnlich zu: ${shortenInline(base, 64)}${detailText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachUrlToExistingPost(postId, urls, payload = {}) {
|
||||||
|
if (!postId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
urls: Array.isArray(urls) ? urls : [],
|
||||||
|
skip_content_key_check: true
|
||||||
|
};
|
||||||
|
if (payload.firstImageHash) {
|
||||||
|
body.first_image_hash = payload.firstImageHash;
|
||||||
|
}
|
||||||
|
if (payload.firstImageUrl) {
|
||||||
|
body.first_image_url = payload.firstImageUrl;
|
||||||
|
}
|
||||||
|
const response = await backendFetch(`${API_URL}/posts/${postId}/urls`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[FB Tracker] Failed to attach URL to existing post:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if post is already tracked (checks all URL candidates to avoid duplicates)
|
// Check if post is already tracked (checks all URL candidates to avoid duplicates)
|
||||||
async function checkPostStatus(postUrl, allUrlCandidates = []) {
|
async function checkPostStatus(postUrl, allUrlCandidates = []) {
|
||||||
try {
|
try {
|
||||||
@@ -880,15 +1040,20 @@ async function persistAlternatePostUrls(postId, urls = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add post to tracking
|
// Add post to tracking
|
||||||
async function markPostChecked(postId, profileNumber) {
|
async function markPostChecked(postId, profileNumber, options = {}) {
|
||||||
try {
|
try {
|
||||||
|
const ignoreOrder = options && options.ignoreOrder === true;
|
||||||
|
const returnError = options && options.returnError === true;
|
||||||
console.log('[FB Tracker] Marking post as checked:', postId, 'Profile:', profileNumber);
|
console.log('[FB Tracker] Marking post as checked:', postId, 'Profile:', profileNumber);
|
||||||
const response = await backendFetch(`${API_URL}/posts/${postId}/check`, {
|
const response = await backendFetch(`${API_URL}/posts/${postId}/check`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ profile_number: profileNumber })
|
body: JSON.stringify({
|
||||||
|
profile_number: profileNumber,
|
||||||
|
ignore_order: ignoreOrder
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -898,15 +1063,21 @@ async function markPostChecked(postId, profileNumber) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 409) {
|
if (response.status === 409) {
|
||||||
console.log('[FB Tracker] Post already checked by this profile');
|
const payload = await response.json().catch(() => ({}));
|
||||||
return null;
|
const message = payload && payload.error ? payload.error : 'Beitrag kann aktuell nicht bestätigt werden.';
|
||||||
|
console.log('[FB Tracker] Post check blocked:', message);
|
||||||
|
return returnError ? { error: message, status: response.status } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('[FB Tracker] Failed to mark post as checked:', response.status);
|
console.error('[FB Tracker] Failed to mark post as checked:', response.status);
|
||||||
return null;
|
return returnError
|
||||||
|
? { error: 'Beitrag konnte nicht bestätigt werden.', status: response.status }
|
||||||
|
: null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[FB Tracker] Error marking post as checked:', error);
|
console.error('[FB Tracker] Error marking post as checked:', error);
|
||||||
return null;
|
return (options && options.returnError)
|
||||||
|
? { error: 'Beitrag konnte nicht bestätigt werden.', status: 0 }
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,7 +1091,9 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options =
|
|||||||
}
|
}
|
||||||
|
|
||||||
let postText = null;
|
let postText = null;
|
||||||
if (options && options.postElement) {
|
if (options && typeof options.postText === 'string') {
|
||||||
|
postText = options.postText;
|
||||||
|
} else if (options && options.postElement) {
|
||||||
try {
|
try {
|
||||||
postText = extractPostText(options.postElement) || null;
|
postText = extractPostText(options.postElement) || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -967,6 +1140,13 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options =
|
|||||||
payload.post_text = postText;
|
payload.post_text = postText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options && typeof options.firstImageHash === 'string' && options.firstImageHash.trim()) {
|
||||||
|
payload.first_image_hash = options.firstImageHash.trim();
|
||||||
|
}
|
||||||
|
if (options && typeof options.firstImageUrl === 'string' && options.firstImageUrl.trim()) {
|
||||||
|
payload.first_image_url = options.firstImageUrl.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const response = await backendFetch(`${API_URL}/posts`, {
|
const response = await backendFetch(`${API_URL}/posts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1667,6 +1847,18 @@ async function captureElementScreenshot(element) {
|
|||||||
const endY = Math.min(documentHeight, elementBottom + verticalMargin);
|
const endY = Math.min(documentHeight, elementBottom + verticalMargin);
|
||||||
const baseDocTop = Math.max(0, elementTop - verticalMargin);
|
const baseDocTop = Math.max(0, elementTop - verticalMargin);
|
||||||
|
|
||||||
|
const restoreScrollPosition = () => {
|
||||||
|
window.scrollTo({ top: originalScrollY, left: originalScrollX, behavior: 'auto' });
|
||||||
|
if (document.documentElement) {
|
||||||
|
document.documentElement.scrollTop = originalScrollY;
|
||||||
|
document.documentElement.scrollLeft = originalScrollX;
|
||||||
|
}
|
||||||
|
if (document.body) {
|
||||||
|
document.body.scrollTop = originalScrollY;
|
||||||
|
document.body.scrollLeft = originalScrollX;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let iteration = 0;
|
let iteration = 0;
|
||||||
let targetScroll = startY;
|
let targetScroll = startY;
|
||||||
@@ -1723,7 +1915,9 @@ async function captureElementScreenshot(element) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
window.scrollTo({ top: originalScrollY, left: originalScrollX, behavior: 'auto' });
|
restoreScrollPosition();
|
||||||
|
await delay(0);
|
||||||
|
restoreScrollPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!segments.length) {
|
if (!segments.length) {
|
||||||
@@ -1845,6 +2039,125 @@ async function maybeDownscaleScreenshot(imageData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLikelyPostImage(img) {
|
||||||
|
if (!img) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const src = img.currentSrc || img.src || '';
|
||||||
|
if (!src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (src.startsWith('data:')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lowerSrc = src.toLowerCase();
|
||||||
|
if (lowerSrc.includes('emoji') || lowerSrc.includes('static.xx') || lowerSrc.includes('sprite')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const width = img.naturalWidth || img.width || 0;
|
||||||
|
const height = img.naturalHeight || img.height || 0;
|
||||||
|
if (width < 120 || height < 120) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForImageLoad(img, timeoutMs = 1500) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!img) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (img.complete && img.naturalWidth > 0) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let resolved = false;
|
||||||
|
const finish = (value) => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finish(false), timeoutMs);
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
finish(true);
|
||||||
|
}, { once: true });
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
finish(false);
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDHashFromPixels(imageData) {
|
||||||
|
if (!imageData || !imageData.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { data } = imageData;
|
||||||
|
const bits = [];
|
||||||
|
for (let y = 0; y < 8; y += 1) {
|
||||||
|
for (let x = 0; x < 8; x += 1) {
|
||||||
|
const leftIndex = ((y * 9) + x) * 4;
|
||||||
|
const rightIndex = ((y * 9) + x + 1) * 4;
|
||||||
|
const left = 0.299 * data[leftIndex] + 0.587 * data[leftIndex + 1] + 0.114 * data[leftIndex + 2];
|
||||||
|
const right = 0.299 * data[rightIndex] + 0.587 * data[rightIndex + 1] + 0.114 * data[rightIndex + 2];
|
||||||
|
bits.push(left > right ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let hex = '';
|
||||||
|
for (let i = 0; i < bits.length; i += 4) {
|
||||||
|
const value = (bits[i] << 3) | (bits[i + 1] << 2) | (bits[i + 2] << 1) | bits[i + 3];
|
||||||
|
hex += value.toString(16);
|
||||||
|
}
|
||||||
|
return hex.padStart(16, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeDHashFromUrl(imageUrl) {
|
||||||
|
if (!imageUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 9;
|
||||||
|
canvas.height = 8;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, 0, 0, 9, 8);
|
||||||
|
const imageData = ctx.getImageData(0, 0, 9, 8);
|
||||||
|
return buildDHashFromPixels(imageData);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFirstPostImageInfo(postElement) {
|
||||||
|
if (!postElement) {
|
||||||
|
return { hash: null, url: null };
|
||||||
|
}
|
||||||
|
const images = Array.from(postElement.querySelectorAll('img')).filter(isLikelyPostImage);
|
||||||
|
for (const img of images.slice(0, 5)) {
|
||||||
|
const loaded = await waitForImageLoad(img);
|
||||||
|
if (!loaded) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const src = img.currentSrc || img.src;
|
||||||
|
const hash = await computeDHashFromUrl(src);
|
||||||
|
if (hash) {
|
||||||
|
return { hash, url: src };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { hash: null, url: null };
|
||||||
|
}
|
||||||
|
|
||||||
function getStickyHeaderHeight() {
|
function getStickyHeaderHeight() {
|
||||||
try {
|
try {
|
||||||
const banner = document.querySelector('[role="banner"], header[role="banner"]');
|
const banner = document.querySelector('[role="banner"], header[role="banner"]');
|
||||||
@@ -1939,6 +2252,9 @@ function extractDeadlineFromPostText(postElement) {
|
|||||||
|
|
||||||
const extractTimeAfterIndex = (text, index) => {
|
const extractTimeAfterIndex = (text, index) => {
|
||||||
const tail = text.slice(index, index + 80);
|
const tail = text.slice(index, index + 80);
|
||||||
|
if (/^\s*(?:-|–|—|bis)\s*\d{1,2}\.\d{1,2}\./i.test(tail)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const timeMatch = /^\s*(?:\(|\[)?\s*(?:[,;:-]|\b)?\s*(?:um|ab|bis|gegen|spätestens)?\s*(?:den|dem|am)?\s*(?:ca\.?)?\s*(\d{1,2})(?:[:.](\d{2}))?\s*(?:uhr|h)?\s*(?:\)|\])?/i.exec(tail);
|
const timeMatch = /^\s*(?:\(|\[)?\s*(?:[,;:-]|\b)?\s*(?:um|ab|bis|gegen|spätestens)?\s*(?:den|dem|am)?\s*(?:ca\.?)?\s*(\d{1,2})(?:[:.](\d{2}))?\s*(?:uhr|h)?\s*(?:\)|\])?/i.exec(tail);
|
||||||
if (!timeMatch) {
|
if (!timeMatch) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1950,6 +2266,9 @@ function extractDeadlineFromPostText(postElement) {
|
|||||||
if (Number.isNaN(hour) || Number.isNaN(minute)) {
|
if (Number.isNaN(hour) || Number.isNaN(minute)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (hour === 24 && minute === 0) {
|
||||||
|
return { hour: 23, minute: 59 };
|
||||||
|
}
|
||||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1963,6 +2282,22 @@ function extractDeadlineFromPostText(postElement) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const foundDates = [];
|
const foundDates = [];
|
||||||
|
const rangePattern = /\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\s*(?:-|–|—|bis)\s*(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/i;
|
||||||
|
const rangeMatch = rangePattern.exec(fullText);
|
||||||
|
if (rangeMatch) {
|
||||||
|
const endDay = parseInt(rangeMatch[4], 10);
|
||||||
|
const endMonth = parseInt(rangeMatch[5], 10);
|
||||||
|
let endYear = parseInt(rangeMatch[6], 10);
|
||||||
|
if (endYear < 100) {
|
||||||
|
endYear += 2000;
|
||||||
|
}
|
||||||
|
if (endMonth >= 1 && endMonth <= 12 && endDay >= 1 && endDay <= 31) {
|
||||||
|
const endDate = new Date(endYear, endMonth - 1, endDay, 0, 0, 0, 0);
|
||||||
|
if (endDate.getMonth() === endMonth - 1 && endDate.getDate() === endDay && endDate > today) {
|
||||||
|
return toDateTimeLocalString(endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
let match;
|
let match;
|
||||||
@@ -2404,7 +2739,19 @@ async function renderTrackedStatus({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false;
|
const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false;
|
||||||
const canCurrentProfileCheck = postData.next_required_profile === profileNumber;
|
const requiredProfiles = Array.isArray(postData.required_profiles) && postData.required_profiles.length
|
||||||
|
? postData.required_profiles
|
||||||
|
.map((value) => {
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.min(5, Math.max(1, parsed));
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
: Array.from({ length: Math.max(1, Math.min(5, parseInt(postData.target_count, 10) || 1)) }, (_, index) => index + 1);
|
||||||
|
const isCurrentProfileRequired = requiredProfiles.includes(profileNumber);
|
||||||
|
const canCurrentProfileCheck = isCurrentProfileRequired && postData.next_required_profile === profileNumber;
|
||||||
const isCurrentProfileDone = checks.some(check => check.profile_number === profileNumber);
|
const isCurrentProfileDone = checks.some(check => check.profile_number === profileNumber);
|
||||||
|
|
||||||
if (isFeedHome && isCurrentProfileDone) {
|
if (isFeedHome && isCurrentProfileDone) {
|
||||||
@@ -2434,16 +2781,23 @@ async function renderTrackedStatus({
|
|||||||
${lastCheck ? `<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${lastCheck}</div>` : ''}
|
${lastCheck ? `<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${lastCheck}</div>` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (canCurrentProfileCheck && !isExpired && !completed) {
|
if (!isExpired && !completed && !isCurrentProfileDone && isCurrentProfileRequired) {
|
||||||
|
const checkButtonEnabled = canCurrentProfileCheck;
|
||||||
|
const buttonColor = checkButtonEnabled ? '#42b72a' : '#f39c12';
|
||||||
|
const cursorStyle = 'pointer';
|
||||||
|
const buttonTitle = checkButtonEnabled
|
||||||
|
? 'Beitrag bestätigen'
|
||||||
|
: 'Wartet auf vorherige Profile';
|
||||||
|
|
||||||
statusHtml += `
|
statusHtml += `
|
||||||
<button class="fb-tracker-check-btn" style="
|
<button class="fb-tracker-check-btn" title="${buttonTitle}" style="
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
background-color: #42b72a;
|
background-color: ${buttonColor};
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: ${cursorStyle};
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
@@ -2524,9 +2878,9 @@ async function renderTrackedStatus({
|
|||||||
checkBtn.disabled = true;
|
checkBtn.disabled = true;
|
||||||
checkBtn.textContent = 'Wird bestätigt...';
|
checkBtn.textContent = 'Wird bestätigt...';
|
||||||
|
|
||||||
const result = await markPostChecked(postData.id, profileNumber);
|
const result = await markPostChecked(postData.id, profileNumber, { returnError: true });
|
||||||
|
|
||||||
if (result) {
|
if (result && !result.error) {
|
||||||
await renderTrackedStatus({
|
await renderTrackedStatus({
|
||||||
container,
|
container,
|
||||||
postElement,
|
postElement,
|
||||||
@@ -2540,8 +2894,13 @@ async function renderTrackedStatus({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
checkBtn.disabled = false;
|
checkBtn.disabled = false;
|
||||||
checkBtn.textContent = 'Fehler - Erneut versuchen';
|
checkBtn.textContent = '✓ Bestätigen';
|
||||||
checkBtn.style.backgroundColor = '#e74c3c';
|
if (result && result.error) {
|
||||||
|
showToast(result.error, 'error');
|
||||||
|
} else {
|
||||||
|
checkBtn.textContent = 'Fehler - Erneut versuchen';
|
||||||
|
checkBtn.style.backgroundColor = '#e74c3c';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2772,6 +3131,36 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
max-width: 160px;
|
max-width: 160px;
|
||||||
" />
|
" />
|
||||||
|
<div class="fb-tracker-similarity" style="
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #f0c36d;
|
||||||
|
background: #fff6d5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b5b00;
|
||||||
|
flex-basis: 100%;
|
||||||
|
">
|
||||||
|
<span class="fb-tracker-similarity__text"></span>
|
||||||
|
<button class="fb-tracker-merge-btn" type="button" style="
|
||||||
|
border: 1px solid #caa848;
|
||||||
|
background: white;
|
||||||
|
color: #7a5d00;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
">Mergen</button>
|
||||||
|
<a class="fb-tracker-similarity-link" href="#" target="_blank" rel="noopener" style="
|
||||||
|
color: #7a5d00;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
display: none;
|
||||||
|
">Öffnen</a>
|
||||||
|
</div>
|
||||||
<button class="fb-tracker-add-btn" style="
|
<button class="fb-tracker-add-btn" style="
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -2792,8 +3181,109 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
|
|||||||
const selectElement = container.querySelector(`#${selectId}`);
|
const selectElement = container.querySelector(`#${selectId}`);
|
||||||
const deadlineInput = container.querySelector(`#${deadlineId}`);
|
const deadlineInput = container.querySelector(`#${deadlineId}`);
|
||||||
selectElement.value = '2';
|
selectElement.value = '2';
|
||||||
|
const similarityBox = container.querySelector('.fb-tracker-similarity');
|
||||||
|
const similarityText = container.querySelector('.fb-tracker-similarity__text');
|
||||||
|
const mergeButton = container.querySelector('.fb-tracker-merge-btn');
|
||||||
|
const similarityLink = container.querySelector('.fb-tracker-similarity-link');
|
||||||
|
const similarityPayloadPromise = buildSimilarityPayload(postElement);
|
||||||
|
let similarityPayload = null;
|
||||||
|
let similarityMatch = null;
|
||||||
const mainLinkUrl = postUrlData.mainUrl;
|
const mainLinkUrl = postUrlData.mainUrl;
|
||||||
|
|
||||||
|
const resolveSimilarityPayload = async () => {
|
||||||
|
if (!similarityPayload) {
|
||||||
|
similarityPayload = await similarityPayloadPromise;
|
||||||
|
}
|
||||||
|
return similarityPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (similarityBox && similarityText && mergeButton) {
|
||||||
|
(async () => {
|
||||||
|
const payload = await resolveSimilarityPayload();
|
||||||
|
const similarity = await findSimilarPost({
|
||||||
|
url: postUrlData.url,
|
||||||
|
postText: payload.postText,
|
||||||
|
firstImageHash: payload.firstImageHash
|
||||||
|
});
|
||||||
|
if (!similarity || !similarity.match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
similarityMatch = similarity.match;
|
||||||
|
similarityText.textContent = formatSimilarityLabel(similarity);
|
||||||
|
similarityBox.style.display = 'flex';
|
||||||
|
if (!addButton.disabled) {
|
||||||
|
addButton.textContent = 'Neu speichern';
|
||||||
|
}
|
||||||
|
if (similarityLink) {
|
||||||
|
similarityLink.style.display = 'inline';
|
||||||
|
similarityLink.textContent = 'Öffnen';
|
||||||
|
if (similarityMatch.url) {
|
||||||
|
similarityLink.href = similarityMatch.url;
|
||||||
|
similarityLink.dataset.ready = '1';
|
||||||
|
} else {
|
||||||
|
similarityLink.href = '#';
|
||||||
|
similarityLink.dataset.ready = '0';
|
||||||
|
similarityLink.dataset.postId = similarityMatch.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
mergeButton.addEventListener('click', async () => {
|
||||||
|
if (!similarityMatch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mergeButton.disabled = true;
|
||||||
|
const previousLabel = mergeButton.textContent;
|
||||||
|
mergeButton.textContent = 'Mergen...';
|
||||||
|
|
||||||
|
const payload = await resolveSimilarityPayload();
|
||||||
|
const urlCandidates = [postUrlData.url, ...postUrlData.allCandidates];
|
||||||
|
const uniqueUrls = Array.from(new Set(urlCandidates.filter(Boolean)));
|
||||||
|
const attached = await attachUrlToExistingPost(similarityMatch.id, uniqueUrls, payload);
|
||||||
|
|
||||||
|
if (attached) {
|
||||||
|
const updatedPost = await fetchPostByUrl(similarityMatch.url);
|
||||||
|
if (updatedPost) {
|
||||||
|
await renderTrackedStatus({
|
||||||
|
container,
|
||||||
|
postElement,
|
||||||
|
postData: updatedPost,
|
||||||
|
profileNumber,
|
||||||
|
isFeedHome,
|
||||||
|
isDialogContext,
|
||||||
|
manualHideInfo,
|
||||||
|
encodedUrl,
|
||||||
|
postNum
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeButton.disabled = false;
|
||||||
|
mergeButton.textContent = previousLabel;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (similarityLink && !similarityLink.dataset.bound) {
|
||||||
|
similarityLink.dataset.bound = '1';
|
||||||
|
similarityLink.addEventListener('click', async (event) => {
|
||||||
|
if (similarityLink.dataset.ready === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const postId = similarityLink.dataset.postId;
|
||||||
|
if (!postId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = await fetchPostById(postId);
|
||||||
|
if (resolved && resolved.url) {
|
||||||
|
similarityLink.href = resolved.url;
|
||||||
|
similarityLink.dataset.ready = '1';
|
||||||
|
window.open(resolved.url, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (mainLinkUrl) {
|
if (mainLinkUrl) {
|
||||||
const mainLinkButton = document.createElement('button');
|
const mainLinkButton = document.createElement('button');
|
||||||
mainLinkButton.className = 'fb-tracker-mainlink-btn';
|
mainLinkButton.className = 'fb-tracker-mainlink-btn';
|
||||||
@@ -2838,11 +3328,15 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
|
|||||||
await delay(220);
|
await delay(220);
|
||||||
|
|
||||||
const deadlineValue = deadlineInput ? deadlineInput.value : '';
|
const deadlineValue = deadlineInput ? deadlineInput.value : '';
|
||||||
|
const payload = await resolveSimilarityPayload();
|
||||||
|
|
||||||
const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, {
|
const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, {
|
||||||
postElement,
|
postElement,
|
||||||
deadline: deadlineValue,
|
deadline: deadlineValue,
|
||||||
candidates: postUrlData.allCandidates
|
candidates: postUrlData.allCandidates,
|
||||||
|
postText: payload.postText,
|
||||||
|
firstImageHash: payload.firstImageHash,
|
||||||
|
firstImageUrl: payload.firstImageUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -3236,6 +3730,10 @@ let globalPostCounter = 0;
|
|||||||
|
|
||||||
// Find all Facebook posts on the page
|
// Find all Facebook posts on the page
|
||||||
function findPosts() {
|
function findPosts() {
|
||||||
|
if (maybeRedirectPageReelsToMain()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[FB Tracker] Scanning for posts...');
|
console.log('[FB Tracker] Scanning for posts...');
|
||||||
|
|
||||||
const postContainers = findPostContainers();
|
const postContainers = findPostContainers();
|
||||||
@@ -3343,6 +3841,7 @@ function findPosts() {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
console.log('[FB Tracker] Initializing...');
|
console.log('[FB Tracker] Initializing...');
|
||||||
|
maybeRedirectPageReelsToMain();
|
||||||
|
|
||||||
// Run multiple times to catch loading posts
|
// Run multiple times to catch loading posts
|
||||||
setTimeout(findPosts, 2000);
|
setTimeout(findPosts, 2000);
|
||||||
@@ -4055,6 +4554,32 @@ function extractPostText(postElement) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasEmojiChars = (text) => /[\uD800-\uDBFF][\uDC00-\uDFFF]|[\u2600-\u27BF]/.test(text);
|
||||||
|
|
||||||
|
const injectEmojiLabels = (root) => {
|
||||||
|
if (!root || typeof root.querySelectorAll !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emojiNodes = root.querySelectorAll('img[alt], [role="img"][aria-label]');
|
||||||
|
emojiNodes.forEach((node) => {
|
||||||
|
const label = node.getAttribute('alt') || node.getAttribute('aria-label');
|
||||||
|
if (!label || !hasEmojiChars(label)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const textNode = node.ownerDocument.createTextNode(label);
|
||||||
|
node.replaceWith(textNode);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextWithEmojis = (element) => {
|
||||||
|
if (!element) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const clone = element.cloneNode(true);
|
||||||
|
injectEmojiLabels(clone);
|
||||||
|
return clone.innerText || clone.textContent || '';
|
||||||
|
};
|
||||||
|
|
||||||
const SKIP_TEXT_CONTAINERS_SELECTOR = [
|
const SKIP_TEXT_CONTAINERS_SELECTOR = [
|
||||||
'div[role="textbox"]',
|
'div[role="textbox"]',
|
||||||
'[contenteditable="true"]',
|
'[contenteditable="true"]',
|
||||||
@@ -4177,7 +4702,7 @@ function extractPostText(postElement) {
|
|||||||
logPostText('Selector result skipped (inside disallowed region)', selector, makeSnippet(element.innerText || element.textContent || ''));
|
logPostText('Selector result skipped (inside disallowed region)', selector, makeSnippet(element.innerText || element.textContent || ''));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tryAddCandidate(element.innerText || element.textContent || '', element, { selector });
|
tryAddCandidate(getTextWithEmojis(element), element, { selector });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4200,11 +4725,12 @@ function extractPostText(postElement) {
|
|||||||
const clone = postElement.cloneNode(true);
|
const clone = postElement.cloneNode(true);
|
||||||
const elementsToRemove = clone.querySelectorAll(SKIP_TEXT_CONTAINERS_SELECTOR);
|
const elementsToRemove = clone.querySelectorAll(SKIP_TEXT_CONTAINERS_SELECTOR);
|
||||||
elementsToRemove.forEach((node) => node.remove());
|
elementsToRemove.forEach((node) => node.remove());
|
||||||
|
injectEmojiLabels(clone);
|
||||||
const cloneText = clone.innerText || clone.textContent || '';
|
const cloneText = clone.innerText || clone.textContent || '';
|
||||||
fallbackText = cleanCandidate(cloneText);
|
fallbackText = cleanCandidate(cloneText);
|
||||||
logPostText('Fallback extracted from clone', makeSnippet(fallbackText || cloneText));
|
logPostText('Fallback extracted from clone', makeSnippet(fallbackText || cloneText));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const allText = postElement.innerText || postElement.textContent || '';
|
const allText = getTextWithEmojis(postElement);
|
||||||
fallbackText = cleanCandidate(allText);
|
fallbackText = cleanCandidate(allText);
|
||||||
logPostText('Fallback extracted from original element', makeSnippet(fallbackText || allText));
|
logPostText('Fallback extracted from original element', makeSnippet(fallbackText || allText));
|
||||||
}
|
}
|
||||||
@@ -5071,19 +5597,19 @@ async function addAICommentButton(container, postElement) {
|
|||||||
let postId = container.dataset.postId || '';
|
let postId = container.dataset.postId || '';
|
||||||
|
|
||||||
if (postId) {
|
if (postId) {
|
||||||
latestData = await markPostChecked(postId, effectiveProfile);
|
latestData = await markPostChecked(postId, effectiveProfile, { ignoreOrder: true });
|
||||||
if (!latestData && decodedUrl) {
|
if (!latestData && decodedUrl) {
|
||||||
const refreshed = await checkPostStatus(decodedUrl);
|
const refreshed = await checkPostStatus(decodedUrl);
|
||||||
if (refreshed && refreshed.id) {
|
if (refreshed && refreshed.id) {
|
||||||
container.dataset.postId = refreshed.id;
|
container.dataset.postId = refreshed.id;
|
||||||
latestData = await markPostChecked(refreshed.id, effectiveProfile) || refreshed;
|
latestData = await markPostChecked(refreshed.id, effectiveProfile, { ignoreOrder: true }) || refreshed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (decodedUrl) {
|
} else if (decodedUrl) {
|
||||||
const refreshed = await checkPostStatus(decodedUrl);
|
const refreshed = await checkPostStatus(decodedUrl);
|
||||||
if (refreshed && refreshed.id) {
|
if (refreshed && refreshed.id) {
|
||||||
container.dataset.postId = refreshed.id;
|
container.dataset.postId = refreshed.id;
|
||||||
latestData = await markPostChecked(refreshed.id, effectiveProfile) || refreshed;
|
latestData = await markPostChecked(refreshed.id, effectiveProfile, { ignoreOrder: true }) || refreshed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
481
web/app.js
481
web/app.js
@@ -300,6 +300,14 @@ const includeExpiredToggle = document.getElementById('includeExpiredToggle');
|
|||||||
const mergeControls = document.getElementById('mergeControls');
|
const mergeControls = document.getElementById('mergeControls');
|
||||||
const mergeModeToggle = document.getElementById('mergeModeToggle');
|
const mergeModeToggle = document.getElementById('mergeModeToggle');
|
||||||
const mergeSubmitBtn = document.getElementById('mergeSubmitBtn');
|
const mergeSubmitBtn = document.getElementById('mergeSubmitBtn');
|
||||||
|
const pendingBulkControls = document.getElementById('pendingBulkControls');
|
||||||
|
const pendingBulkCountSelect = document.getElementById('pendingBulkCountSelect');
|
||||||
|
const pendingBulkOpenBtn = document.getElementById('pendingBulkOpenBtn');
|
||||||
|
const pendingAutoOpenToggle = document.getElementById('pendingAutoOpenToggle');
|
||||||
|
const pendingAutoOpenOverlay = document.getElementById('pendingAutoOpenOverlay');
|
||||||
|
const pendingAutoOpenOverlayPanel = document.getElementById('pendingAutoOpenOverlayPanel');
|
||||||
|
const pendingAutoOpenCountdown = document.getElementById('pendingAutoOpenCountdown');
|
||||||
|
const pendingBulkStatus = document.getElementById('pendingBulkStatus');
|
||||||
|
|
||||||
const REFRESH_SETTINGS_KEY = 'trackerRefreshSettings';
|
const REFRESH_SETTINGS_KEY = 'trackerRefreshSettings';
|
||||||
const SORT_SETTINGS_KEY = 'trackerSortSettings';
|
const SORT_SETTINGS_KEY = 'trackerSortSettings';
|
||||||
@@ -313,6 +321,12 @@ const BOOKMARK_WINDOW_DAYS = 28;
|
|||||||
const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen'];
|
const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen'];
|
||||||
const BOOKMARK_PREFS_KEY = 'trackerBookmarkPreferences';
|
const BOOKMARK_PREFS_KEY = 'trackerBookmarkPreferences';
|
||||||
const INCLUDE_EXPIRED_STORAGE_KEY = 'trackerIncludeExpired';
|
const INCLUDE_EXPIRED_STORAGE_KEY = 'trackerIncludeExpired';
|
||||||
|
const PENDING_BULK_COUNT_STORAGE_KEY = 'trackerPendingBulkCount';
|
||||||
|
const PENDING_AUTO_OPEN_STORAGE_KEY = 'trackerPendingAutoOpen';
|
||||||
|
const DEFAULT_PENDING_BULK_COUNT = 5;
|
||||||
|
const PENDING_AUTO_OPEN_DELAY_MS = 1500;
|
||||||
|
const PENDING_OPEN_COOLDOWN_STORAGE_KEY = 'trackerPendingOpenCooldown';
|
||||||
|
const PENDING_OPEN_COOLDOWN_MS = 40 * 60 * 1000;
|
||||||
|
|
||||||
function loadIncludeExpiredPreference() {
|
function loadIncludeExpiredPreference() {
|
||||||
try {
|
try {
|
||||||
@@ -337,6 +351,110 @@ function persistIncludeExpiredPreference(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPendingOpenCooldownStorageKey(profileNumber = currentProfile) {
|
||||||
|
const safeProfile = profileNumber || currentProfile || 1;
|
||||||
|
return `${PENDING_OPEN_COOLDOWN_STORAGE_KEY}:${safeProfile}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPendingOpenCooldownMap(profileNumber = currentProfile) {
|
||||||
|
const storageKey = getPendingOpenCooldownStorageKey(profileNumber);
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
const now = Date.now();
|
||||||
|
const cleaned = {};
|
||||||
|
Object.entries(parsed).forEach(([id, timestamp]) => {
|
||||||
|
const value = Number(timestamp);
|
||||||
|
if (Number.isFinite(value) && now - value < PENDING_OPEN_COOLDOWN_MS) {
|
||||||
|
cleaned[id] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(cleaned).length !== Object.keys(parsed).length) {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(cleaned));
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Konnte Cooldown-Daten für offene Beiträge nicht laden:', error);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPendingOpenCooldownMap(profileNumber, map) {
|
||||||
|
const storageKey = getPendingOpenCooldownStorageKey(profileNumber);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(map || {}));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Konnte Cooldown-Daten für offene Beiträge nicht speichern:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPendingOpenCooldownActive(postId) {
|
||||||
|
if (!postId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const timestamp = pendingOpenCooldownMap[postId];
|
||||||
|
if (!timestamp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const elapsed = Date.now() - timestamp;
|
||||||
|
if (elapsed < PENDING_OPEN_COOLDOWN_MS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
delete pendingOpenCooldownMap[postId];
|
||||||
|
persistPendingOpenCooldownMap(currentProfile, pendingOpenCooldownMap);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordPendingOpen(postId) {
|
||||||
|
if (!postId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingOpenCooldownMap[postId] = Date.now();
|
||||||
|
persistPendingOpenCooldownMap(currentProfile, pendingOpenCooldownMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPendingBulkCount() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(PENDING_BULK_COUNT_STORAGE_KEY);
|
||||||
|
const value = parseInt(stored, 10);
|
||||||
|
if (!Number.isNaN(value) && [1, 5, 10, 15, 20].includes(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Konnte Bulk-Anzahl für offene Beiträge nicht laden:', error);
|
||||||
|
}
|
||||||
|
return DEFAULT_PENDING_BULK_COUNT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPendingBulkCount(value) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PENDING_BULK_COUNT_STORAGE_KEY, String(value));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Konnte Bulk-Anzahl für offene Beiträge nicht speichern:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPendingAutoOpenEnabled() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(PENDING_AUTO_OPEN_STORAGE_KEY) === '1';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Konnte Auto-Öffnen-Status nicht laden:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPendingAutoOpenEnabled(enabled) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PENDING_AUTO_OPEN_STORAGE_KEY, enabled ? '1' : '0');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Konnte Auto-Öffnen-Status nicht speichern:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateIncludeExpiredToggleUI() {
|
function updateIncludeExpiredToggleUI() {
|
||||||
if (!includeExpiredToggle) {
|
if (!includeExpiredToggle) {
|
||||||
return;
|
return;
|
||||||
@@ -345,6 +463,13 @@ function updateIncludeExpiredToggleUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
includeExpiredPosts = loadIncludeExpiredPreference();
|
includeExpiredPosts = loadIncludeExpiredPreference();
|
||||||
|
let pendingBulkCount = loadPendingBulkCount();
|
||||||
|
let pendingAutoOpenEnabled = loadPendingAutoOpenEnabled();
|
||||||
|
let pendingAutoOpenTriggered = false;
|
||||||
|
let pendingAutoOpenTimerId = null;
|
||||||
|
let pendingAutoOpenCountdownIntervalId = null;
|
||||||
|
let pendingProcessingBatch = false;
|
||||||
|
let pendingOpenCooldownMap = loadPendingOpenCooldownMap(currentProfile);
|
||||||
|
|
||||||
function updateIncludeExpiredToggleVisibility() {
|
function updateIncludeExpiredToggleVisibility() {
|
||||||
if (!includeExpiredToggle) {
|
if (!includeExpiredToggle) {
|
||||||
@@ -388,6 +513,26 @@ function updateMergeControlsUI() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePendingBulkControls(filteredCount = 0) {
|
||||||
|
if (!pendingBulkControls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isPendingTab = currentTab === 'pending';
|
||||||
|
pendingBulkControls.hidden = !isPendingTab;
|
||||||
|
pendingBulkControls.style.display = isPendingTab ? 'flex' : 'none';
|
||||||
|
if (pendingBulkOpenBtn) {
|
||||||
|
pendingBulkOpenBtn.disabled = !isPendingTab || pendingProcessingBatch || filteredCount === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPendingBulkStatus(message = '', isError = false) {
|
||||||
|
if (!pendingBulkStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingBulkStatus.textContent = message || '';
|
||||||
|
pendingBulkStatus.classList.toggle('bulk-status--error', !!isError);
|
||||||
|
}
|
||||||
|
|
||||||
function initializeFocusParams() {
|
function initializeFocusParams() {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -1635,6 +1780,16 @@ function updateSortDirectionToggleUI() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDefaultSortDirectionForMode(mode) {
|
||||||
|
if (mode === 'deadline') {
|
||||||
|
return 'asc';
|
||||||
|
}
|
||||||
|
if (mode === 'smart') {
|
||||||
|
return 'desc';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRequiredProfiles(post) {
|
function normalizeRequiredProfiles(post) {
|
||||||
if (Array.isArray(post.required_profiles) && post.required_profiles.length) {
|
if (Array.isArray(post.required_profiles) && post.required_profiles.length) {
|
||||||
return post.required_profiles
|
return post.required_profiles
|
||||||
@@ -1714,6 +1869,224 @@ function updateFilteredCount(tab, count) {
|
|||||||
tabFilteredCounts[key] = Math.max(0, count || 0);
|
tabFilteredCounts[key] = Math.max(0, count || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPostListState() {
|
||||||
|
const postItems = posts.map((post) => ({
|
||||||
|
post,
|
||||||
|
status: computePostStatus(post)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sortedItems = [...postItems].sort(comparePostItems);
|
||||||
|
let filteredItems = sortedItems;
|
||||||
|
|
||||||
|
if (currentTab === 'pending') {
|
||||||
|
filteredItems = sortedItems.filter((item) => !item.status.isExpired && item.status.canCurrentProfileCheck && !item.status.isComplete);
|
||||||
|
} else {
|
||||||
|
filteredItems = includeExpiredPosts
|
||||||
|
? sortedItems
|
||||||
|
: sortedItems.filter((item) => !item.status.isExpired && !item.status.isComplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabTotalCount = filteredItems.length;
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const searchValue = searchInput && typeof searchInput.value === 'string' ? searchInput.value.trim() : '';
|
||||||
|
const searchActive = Boolean(searchValue);
|
||||||
|
|
||||||
|
if (searchActive) {
|
||||||
|
const searchTerm = searchValue.toLowerCase();
|
||||||
|
filteredItems = filteredItems.filter((item) => {
|
||||||
|
const post = item.post;
|
||||||
|
return (
|
||||||
|
(post.title && post.title.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(post.url && post.url.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(post.created_by_name && post.created_by_name.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(post.id && post.id.toLowerCase().includes(searchTerm))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sortedItems,
|
||||||
|
filteredItems,
|
||||||
|
tabTotalCount,
|
||||||
|
searchActive,
|
||||||
|
searchValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingAutoOpenCountdown() {
|
||||||
|
if (pendingAutoOpenCountdownIntervalId) {
|
||||||
|
clearInterval(pendingAutoOpenCountdownIntervalId);
|
||||||
|
pendingAutoOpenCountdownIntervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePendingAutoOpenCountdownLabel(remainingMs) {
|
||||||
|
if (!pendingAutoOpenCountdown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const safeMs = Math.max(0, remainingMs);
|
||||||
|
const seconds = safeMs / 1000;
|
||||||
|
const formatted = seconds >= 10 ? seconds.toFixed(0) : seconds.toFixed(1);
|
||||||
|
pendingAutoOpenCountdown.textContent = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePendingAutoOpenOverlay() {
|
||||||
|
clearPendingAutoOpenCountdown();
|
||||||
|
if (pendingAutoOpenOverlay) {
|
||||||
|
pendingAutoOpenOverlay.classList.remove('visible');
|
||||||
|
pendingAutoOpenOverlay.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPendingAutoOpenOverlay(delayMs) {
|
||||||
|
if (!pendingAutoOpenOverlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const duration = Math.max(0, delayMs);
|
||||||
|
hidePendingAutoOpenOverlay();
|
||||||
|
pendingAutoOpenOverlay.hidden = false;
|
||||||
|
requestAnimationFrame(() => pendingAutoOpenOverlay.classList.add('visible'));
|
||||||
|
updatePendingAutoOpenCountdownLabel(duration);
|
||||||
|
const start = Date.now();
|
||||||
|
pendingAutoOpenCountdownIntervalId = setInterval(() => {
|
||||||
|
const remaining = Math.max(0, duration - (Date.now() - start));
|
||||||
|
updatePendingAutoOpenCountdownLabel(remaining);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearPendingAutoOpenCountdown();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPendingAutoOpen(showMessage = false) {
|
||||||
|
if (pendingAutoOpenTimerId) {
|
||||||
|
clearTimeout(pendingAutoOpenTimerId);
|
||||||
|
pendingAutoOpenTimerId = null;
|
||||||
|
}
|
||||||
|
pendingAutoOpenTriggered = false;
|
||||||
|
hidePendingAutoOpenOverlay();
|
||||||
|
if (showMessage) {
|
||||||
|
setPendingBulkStatus('Automatisches Öffnen abgebrochen.', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingVisibleCandidates() {
|
||||||
|
if (currentTab !== 'pending') {
|
||||||
|
return { items: [], totalVisible: 0, cooldownBlocked: 0 };
|
||||||
|
}
|
||||||
|
const { filteredItems } = getPostListState();
|
||||||
|
const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab));
|
||||||
|
const visibleItems = filteredItems
|
||||||
|
.slice(0, visibleCount)
|
||||||
|
.filter(({ post }) => post && post.url);
|
||||||
|
const items = visibleItems.filter(({ post }) => !isPendingOpenCooldownActive(post.id));
|
||||||
|
const cooldownBlocked = Math.max(0, visibleItems.length - items.length);
|
||||||
|
return { items, totalVisible: visibleItems.length, cooldownBlocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPendingBatch({ auto = false } = {}) {
|
||||||
|
if (pendingProcessingBatch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!auto) {
|
||||||
|
cancelPendingAutoOpen(false);
|
||||||
|
}
|
||||||
|
const { items: candidates, totalVisible, cooldownBlocked } = getPendingVisibleCandidates();
|
||||||
|
if (!candidates.length) {
|
||||||
|
if (!auto) {
|
||||||
|
if (totalVisible === 0) {
|
||||||
|
setPendingBulkStatus('Keine offenen Beiträge zum Öffnen.', true);
|
||||||
|
} else if (cooldownBlocked > 0) {
|
||||||
|
setPendingBulkStatus('Alle sichtbaren Beiträge sind noch im Cooldown (40 min).', true);
|
||||||
|
} else {
|
||||||
|
setPendingBulkStatus('Keine offenen Beiträge zum Öffnen.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingAutoOpenTriggered = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = pendingBulkCount || DEFAULT_PENDING_BULK_COUNT;
|
||||||
|
const selection = candidates.slice(0, count);
|
||||||
|
|
||||||
|
pendingProcessingBatch = true;
|
||||||
|
if (pendingBulkOpenBtn) {
|
||||||
|
pendingBulkOpenBtn.disabled = true;
|
||||||
|
}
|
||||||
|
if (!auto) {
|
||||||
|
setPendingBulkStatus('');
|
||||||
|
} else {
|
||||||
|
setPendingBulkStatus(`Öffne automatisch ${selection.length} Links...`, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
selection.forEach(({ post }) => {
|
||||||
|
if (post && post.url) {
|
||||||
|
window.open(post.url, '_blank', 'noopener');
|
||||||
|
recordPendingOpen(post.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingProcessingBatch = false;
|
||||||
|
if (pendingBulkOpenBtn) {
|
||||||
|
pendingBulkOpenBtn.disabled = false;
|
||||||
|
}
|
||||||
|
if (auto) {
|
||||||
|
setPendingBulkStatus('');
|
||||||
|
pendingAutoOpenTriggered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeAutoOpenPending(reason = '', delayMs = PENDING_AUTO_OPEN_DELAY_MS) {
|
||||||
|
if (!isPostsViewActive()) {
|
||||||
|
hidePendingAutoOpenOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentTab !== 'pending') {
|
||||||
|
hidePendingAutoOpenOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pendingAutoOpenEnabled) {
|
||||||
|
hidePendingAutoOpenOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingProcessingBatch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingAutoOpenTriggered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { items: candidates } = getPendingVisibleCandidates();
|
||||||
|
if (!candidates.length) {
|
||||||
|
hidePendingAutoOpenOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingAutoOpenTimerId) {
|
||||||
|
clearTimeout(pendingAutoOpenTimerId);
|
||||||
|
pendingAutoOpenTimerId = null;
|
||||||
|
}
|
||||||
|
hidePendingAutoOpenOverlay();
|
||||||
|
pendingAutoOpenTriggered = true;
|
||||||
|
const delay = typeof delayMs === 'number' ? Math.max(0, delayMs) : PENDING_AUTO_OPEN_DELAY_MS;
|
||||||
|
if (delay === 0) {
|
||||||
|
if (pendingAutoOpenEnabled) {
|
||||||
|
openPendingBatch({ auto: true });
|
||||||
|
} else {
|
||||||
|
pendingAutoOpenTriggered = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showPendingAutoOpenOverlay(delay);
|
||||||
|
pendingAutoOpenTimerId = setTimeout(() => {
|
||||||
|
pendingAutoOpenTimerId = null;
|
||||||
|
hidePendingAutoOpenOverlay();
|
||||||
|
if (pendingAutoOpenEnabled) {
|
||||||
|
openPendingBatch({ auto: true });
|
||||||
|
} else {
|
||||||
|
pendingAutoOpenTriggered = false;
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupLoadMoreObserver() {
|
function cleanupLoadMoreObserver() {
|
||||||
if (loadMoreObserver && observedLoadMoreElement) {
|
if (loadMoreObserver && observedLoadMoreElement) {
|
||||||
loadMoreObserver.unobserve(observedLoadMoreElement);
|
loadMoreObserver.unobserve(observedLoadMoreElement);
|
||||||
@@ -1828,6 +2201,7 @@ function setTab(tab, { updateUrl = true } = {}) {
|
|||||||
updateTabInUrl();
|
updateTabInUrl();
|
||||||
}
|
}
|
||||||
renderPosts();
|
renderPosts();
|
||||||
|
maybeAutoOpenPending('tab');
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeTabFromUrl() {
|
function initializeTabFromUrl() {
|
||||||
@@ -3011,7 +3385,9 @@ function applyProfileNumber(profileNumber, { fromBackend = false } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetVisibleCount();
|
resetVisibleCount();
|
||||||
|
pendingOpenCooldownMap = loadPendingOpenCooldownMap(currentProfile);
|
||||||
renderPosts();
|
renderPosts();
|
||||||
|
maybeAutoOpenPending('profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load profile from localStorage
|
// Load profile from localStorage
|
||||||
@@ -3066,6 +3442,42 @@ if (includeExpiredToggle) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingBulkCountSelect) {
|
||||||
|
pendingBulkCountSelect.value = String(pendingBulkCount);
|
||||||
|
pendingBulkCountSelect.addEventListener('change', () => {
|
||||||
|
const value = parseInt(pendingBulkCountSelect.value, 10);
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
pendingBulkCount = value;
|
||||||
|
persistPendingBulkCount(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingBulkOpenBtn) {
|
||||||
|
pendingBulkOpenBtn.addEventListener('click', () => openPendingBatch());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingAutoOpenOverlayPanel) {
|
||||||
|
pendingAutoOpenOverlayPanel.addEventListener('click', () => cancelPendingAutoOpen(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingAutoOpenToggle) {
|
||||||
|
pendingAutoOpenToggle.checked = !!pendingAutoOpenEnabled;
|
||||||
|
pendingAutoOpenToggle.addEventListener('change', () => {
|
||||||
|
pendingAutoOpenEnabled = pendingAutoOpenToggle.checked;
|
||||||
|
persistPendingAutoOpenEnabled(pendingAutoOpenEnabled);
|
||||||
|
pendingAutoOpenTriggered = false;
|
||||||
|
if (!pendingAutoOpenEnabled && pendingAutoOpenTimerId) {
|
||||||
|
cancelPendingAutoOpen(false);
|
||||||
|
}
|
||||||
|
if (pendingAutoOpenEnabled) {
|
||||||
|
maybeAutoOpenPending('toggle');
|
||||||
|
} else {
|
||||||
|
hidePendingAutoOpenOverlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
@@ -3164,6 +3576,11 @@ if (sortModeSelect) {
|
|||||||
sortModeSelect.addEventListener('change', () => {
|
sortModeSelect.addEventListener('change', () => {
|
||||||
const value = sortModeSelect.value;
|
const value = sortModeSelect.value;
|
||||||
sortMode = VALID_SORT_MODES.has(value) ? value : DEFAULT_SORT_SETTINGS.mode;
|
sortMode = VALID_SORT_MODES.has(value) ? value : DEFAULT_SORT_SETTINGS.mode;
|
||||||
|
const defaultDirection = getDefaultSortDirectionForMode(sortMode);
|
||||||
|
if (defaultDirection) {
|
||||||
|
sortDirection = defaultDirection;
|
||||||
|
updateSortDirectionToggleUI();
|
||||||
|
}
|
||||||
saveSortMode();
|
saveSortMode();
|
||||||
resetVisibleCount();
|
resetVisibleCount();
|
||||||
renderPosts();
|
renderPosts();
|
||||||
@@ -3201,6 +3618,7 @@ async function fetchPosts({ showLoader = true } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isFetchingPosts = true;
|
isFetchingPosts = true;
|
||||||
|
cancelPendingAutoOpen(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (showLoader) {
|
if (showLoader) {
|
||||||
@@ -3218,6 +3636,7 @@ async function fetchPosts({ showLoader = true } = {}) {
|
|||||||
await normalizeLoadedPostUrls();
|
await normalizeLoadedPostUrls();
|
||||||
sortPostsByCreatedAt();
|
sortPostsByCreatedAt();
|
||||||
renderPosts();
|
renderPosts();
|
||||||
|
maybeAutoOpenPending('load');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (showLoader) {
|
if (showLoader) {
|
||||||
showError('Fehler beim Laden der Beiträge. Stelle sicher, dass das Backend läuft.');
|
showError('Fehler beim Laden der Beiträge. Stelle sicher, dass das Backend läuft.');
|
||||||
@@ -3487,45 +3906,18 @@ function renderPosts() {
|
|||||||
updateTabButtons();
|
updateTabButtons();
|
||||||
cleanupLoadMoreObserver();
|
cleanupLoadMoreObserver();
|
||||||
|
|
||||||
const postItems = posts.map((post) => ({
|
const {
|
||||||
post,
|
sortedItems,
|
||||||
status: computePostStatus(post)
|
filteredItems: filteredItemsResult,
|
||||||
}));
|
tabTotalCount,
|
||||||
|
searchActive
|
||||||
|
} = getPostListState();
|
||||||
|
|
||||||
const sortedItems = [...postItems].sort(comparePostItems);
|
let filteredItems = filteredItemsResult;
|
||||||
const focusCandidateEntry = (!focusHandled && (focusPostIdParam || focusNormalizedUrl))
|
const focusCandidateEntry = (!focusHandled && (focusPostIdParam || focusNormalizedUrl))
|
||||||
? sortedItems.find((item) => doesPostMatchFocus(item.post))
|
? sortedItems.find((item) => doesPostMatchFocus(item.post))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
let filteredItems = sortedItems;
|
|
||||||
|
|
||||||
if (currentTab === 'pending') {
|
|
||||||
filteredItems = sortedItems.filter((item) => !item.status.isExpired && item.status.canCurrentProfileCheck && !item.status.isComplete);
|
|
||||||
} else {
|
|
||||||
filteredItems = includeExpiredPosts
|
|
||||||
? sortedItems
|
|
||||||
: sortedItems.filter((item) => !item.status.isExpired && !item.status.isComplete);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabTotalCount = filteredItems.length;
|
|
||||||
|
|
||||||
const searchInput = document.getElementById('searchInput');
|
|
||||||
const searchValue = searchInput && typeof searchInput.value === 'string' ? searchInput.value.trim() : '';
|
|
||||||
const searchActive = Boolean(searchValue);
|
|
||||||
|
|
||||||
if (searchActive) {
|
|
||||||
const searchTerm = searchValue.toLowerCase();
|
|
||||||
filteredItems = filteredItems.filter((item) => {
|
|
||||||
const post = item.post;
|
|
||||||
return (
|
|
||||||
(post.title && post.title.toLowerCase().includes(searchTerm)) ||
|
|
||||||
(post.url && post.url.toLowerCase().includes(searchTerm)) ||
|
|
||||||
(post.created_by_name && post.created_by_name.toLowerCase().includes(searchTerm)) ||
|
|
||||||
(post.id && post.id.toLowerCase().includes(searchTerm))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!focusHandled && focusCandidateEntry && !searchActive) {
|
if (!focusHandled && focusCandidateEntry && !searchActive) {
|
||||||
const candidateVisibleInCurrentTab = filteredItems.some(({ post }) => doesPostMatchFocus(post));
|
const candidateVisibleInCurrentTab = filteredItems.some(({ post }) => doesPostMatchFocus(post));
|
||||||
if (!candidateVisibleInCurrentTab) {
|
if (!candidateVisibleInCurrentTab) {
|
||||||
@@ -3554,6 +3946,7 @@ function renderPosts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateFilteredCount(currentTab, filteredItems.length);
|
updateFilteredCount(currentTab, filteredItems.length);
|
||||||
|
updatePendingBulkControls(filteredItems.length);
|
||||||
|
|
||||||
const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab));
|
const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab));
|
||||||
const visibleItems = filteredItems.slice(0, visibleCount);
|
const visibleItems = filteredItems.slice(0, visibleCount);
|
||||||
@@ -4597,6 +4990,26 @@ window.addEventListener('resize', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('app:view-change', (event) => {
|
||||||
|
const view = event && event.detail ? event.detail.view : null;
|
||||||
|
if (view === 'posts') {
|
||||||
|
maybeAutoOpenPending('view');
|
||||||
|
} else {
|
||||||
|
cancelPendingAutoOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible' && pendingAutoOpenEnabled) {
|
||||||
|
if (pendingAutoOpenTimerId) {
|
||||||
|
clearTimeout(pendingAutoOpenTimerId);
|
||||||
|
pendingAutoOpenTimerId = null;
|
||||||
|
}
|
||||||
|
pendingAutoOpenTriggered = false;
|
||||||
|
maybeAutoOpenPending('visibility', PENDING_AUTO_OPEN_DELAY_MS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async function bootstrapApp() {
|
async function bootstrapApp() {
|
||||||
const authenticated = await ensureAuthenticated();
|
const authenticated = await ensureAuthenticated();
|
||||||
|
|||||||
@@ -178,11 +178,43 @@
|
|||||||
</label>
|
</label>
|
||||||
<input type="text" id="searchInput" class="search-input" placeholder="Beiträge durchsuchen...">
|
<input type="text" id="searchInput" class="search-input" placeholder="Beiträge durchsuchen...">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="posts-bulk-controls" id="pendingBulkControls" hidden>
|
||||||
|
<div class="bulk-actions">
|
||||||
|
<label for="pendingBulkCountSelect">Anzahl</label>
|
||||||
|
<select id="pendingBulkCountSelect">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="15">15</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
</select>
|
||||||
|
<label class="auto-open-toggle">
|
||||||
|
<input type="checkbox" id="pendingAutoOpenToggle">
|
||||||
|
<span>Auto öffnen</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn btn-secondary" id="pendingBulkOpenBtn">Links öffnen</button>
|
||||||
|
</div>
|
||||||
|
<div id="pendingBulkStatus" class="bulk-status" role="status" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="loading" class="loading">Lade Beiträge...</div>
|
<div id="loading" class="loading">Lade Beiträge...</div>
|
||||||
<div id="error" class="error" style="display: none;"></div>
|
<div id="error" class="error" style="display: none;"></div>
|
||||||
|
|
||||||
|
<div id="pendingAutoOpenOverlay" class="auto-open-overlay" hidden>
|
||||||
|
<div class="auto-open-overlay__panel" id="pendingAutoOpenOverlayPanel">
|
||||||
|
<div class="auto-open-overlay__badge">Auto-Öffnen startet gleich</div>
|
||||||
|
<div class="auto-open-overlay__timer">
|
||||||
|
<span id="pendingAutoOpenCountdown" class="auto-open-overlay__count">0.0</span>
|
||||||
|
<span class="auto-open-overlay__unit">Sek.</span>
|
||||||
|
</div>
|
||||||
|
<p class="auto-open-overlay__text">
|
||||||
|
Die nächsten offenen Beiträge werden automatisch geöffnet. Abbrechen, falls du noch warten willst.
|
||||||
|
</p>
|
||||||
|
<p class="auto-open-overlay__hint">Klicke irgendwo in dieses Panel, um abzubrechen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="postsContainer" class="posts-container"></div>
|
<div id="postsContainer" class="posts-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1181,6 +1213,36 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Similarity settings -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="section-title">Ähnlichkeits-Erkennung</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Steuert, ab wann Posts als ähnlich gelten (Text-Ähnlichkeit oder Bild-Ähnlichkeit).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="similaritySettingsForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="similarityTextThreshold" class="form-label">Text-Ähnlichkeit (0.50–0.99)</label>
|
||||||
|
<input type="number" id="similarityTextThreshold" class="form-input" min="0.5" max="0.99" step="0.01" value="0.85">
|
||||||
|
<p class="form-help">
|
||||||
|
Je höher der Wert, desto strenger wird Text-Ähnlichkeit bewertet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="similarityImageThreshold" class="form-label">Bild-Distanz (0–64)</label>
|
||||||
|
<input type="number" id="similarityImageThreshold" class="form-input" min="0" max="64" step="1" value="6">
|
||||||
|
<p class="form-help">
|
||||||
|
Kleiner Wert = strenger (0 = identischer Hash, 64 = komplett unterschiedlich).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Ähnlichkeit speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Hidden posts / purge settings -->
|
<!-- Hidden posts / purge settings -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="section-title">Versteckte Beiträge bereinigen</h2>
|
<h2 class="section-title">Versteckte Beiträge bereinigen</h2>
|
||||||
|
|||||||
101
web/settings.js
101
web/settings.js
@@ -72,6 +72,10 @@ let moderationSettings = {
|
|||||||
sports_terms: {},
|
sports_terms: {},
|
||||||
sports_auto_hide_enabled: false
|
sports_auto_hide_enabled: false
|
||||||
};
|
};
|
||||||
|
let similaritySettings = {
|
||||||
|
text_threshold: 0.85,
|
||||||
|
image_distance_threshold: 6
|
||||||
|
};
|
||||||
|
|
||||||
function handleUnauthorized(response) {
|
function handleUnauthorized(response) {
|
||||||
if (response && response.status === 401) {
|
if (response && response.status === 401) {
|
||||||
@@ -405,6 +409,84 @@ async function saveModerationSettings(event, { silent = false } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSimilarityTextThresholdInput(value) {
|
||||||
|
const parsed = parseFloat(value);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return 0.85;
|
||||||
|
}
|
||||||
|
return Math.min(0.99, Math.max(0.5, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSimilarityImageThresholdInput(value) {
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
return Math.min(64, Math.max(0, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySimilaritySettingsUI() {
|
||||||
|
const textInput = document.getElementById('similarityTextThreshold');
|
||||||
|
const imageInput = document.getElementById('similarityImageThreshold');
|
||||||
|
if (textInput) {
|
||||||
|
textInput.value = similaritySettings.text_threshold ?? 0.85;
|
||||||
|
}
|
||||||
|
if (imageInput) {
|
||||||
|
imageInput.value = similaritySettings.image_distance_threshold ?? 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSimilaritySettings() {
|
||||||
|
const res = await apiFetch(`${API_URL}/similarity-settings`);
|
||||||
|
if (!res.ok) throw new Error('Konnte Ähnlichkeits-Einstellungen nicht laden');
|
||||||
|
const data = await res.json();
|
||||||
|
similaritySettings = {
|
||||||
|
text_threshold: normalizeSimilarityTextThresholdInput(data.text_threshold),
|
||||||
|
image_distance_threshold: normalizeSimilarityImageThresholdInput(data.image_distance_threshold)
|
||||||
|
};
|
||||||
|
applySimilaritySettingsUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSimilaritySettings(event, { silent = false } = {}) {
|
||||||
|
if (event && typeof event.preventDefault === 'function') {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
const textInput = document.getElementById('similarityTextThreshold');
|
||||||
|
const imageInput = document.getElementById('similarityImageThreshold');
|
||||||
|
const textThreshold = textInput
|
||||||
|
? normalizeSimilarityTextThresholdInput(textInput.value)
|
||||||
|
: similaritySettings.text_threshold;
|
||||||
|
const imageThreshold = imageInput
|
||||||
|
? normalizeSimilarityImageThresholdInput(imageInput.value)
|
||||||
|
: similaritySettings.image_distance_threshold;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`${API_URL}/similarity-settings`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text_threshold: textThreshold,
|
||||||
|
image_distance_threshold: imageThreshold
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || 'Fehler beim Speichern');
|
||||||
|
}
|
||||||
|
similaritySettings = await res.json();
|
||||||
|
applySimilaritySettingsUI();
|
||||||
|
if (!silent) {
|
||||||
|
showSuccess('✅ Ähnlichkeitsregeln gespeichert');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (!silent) {
|
||||||
|
showError('❌ ' + err.message);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shorten(text, maxLength = 80) {
|
function shorten(text, maxLength = 80) {
|
||||||
if (typeof text !== 'string') {
|
if (typeof text !== 'string') {
|
||||||
return '';
|
return '';
|
||||||
@@ -1041,6 +1123,7 @@ async function saveAllSettings(event) {
|
|||||||
saveSettings(null, { silent: true }),
|
saveSettings(null, { silent: true }),
|
||||||
saveHiddenSettings(null, { silent: true }),
|
saveHiddenSettings(null, { silent: true }),
|
||||||
saveModerationSettings(null, { silent: true }),
|
saveModerationSettings(null, { silent: true }),
|
||||||
|
saveSimilaritySettings(null, { silent: true }),
|
||||||
saveAllFriends({ silent: true })
|
saveAllFriends({ silent: true })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1208,12 +1291,30 @@ if (sportsScoringToggle && sportsScoreInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const similarityForm = document.getElementById('similaritySettingsForm');
|
||||||
|
if (similarityForm) {
|
||||||
|
const textInput = document.getElementById('similarityTextThreshold');
|
||||||
|
const imageInput = document.getElementById('similarityImageThreshold');
|
||||||
|
if (textInput) {
|
||||||
|
textInput.addEventListener('blur', () => {
|
||||||
|
textInput.value = normalizeSimilarityTextThresholdInput(textInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (imageInput) {
|
||||||
|
imageInput.addEventListener('blur', () => {
|
||||||
|
imageInput.value = normalizeSimilarityImageThresholdInput(imageInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
similarityForm.addEventListener('submit', (e) => saveSimilaritySettings(e));
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
Promise.all([
|
Promise.all([
|
||||||
loadCredentials(),
|
loadCredentials(),
|
||||||
loadSettings(),
|
loadSettings(),
|
||||||
loadHiddenSettings(),
|
loadHiddenSettings(),
|
||||||
loadModerationSettings(),
|
loadModerationSettings(),
|
||||||
|
loadSimilaritySettings(),
|
||||||
loadProfileFriends()
|
loadProfileFriends()
|
||||||
]).catch(err => showError(err.message));
|
]).catch(err => showError(err.message));
|
||||||
})();
|
})();
|
||||||
|
|||||||
135
web/style.css
135
web/style.css
@@ -589,6 +589,141 @@ h1 {
|
|||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.posts-bulk-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions label {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions select {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
color: #111827;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-open-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-open-toggle input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-status--error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-open-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 20%, rgba(37, 99, 235, 0.12), transparent 42%),
|
||||||
|
radial-gradient(circle at 80% 30%, rgba(6, 182, 212, 0.12), transparent 38%),
|
||||||
|
rgba(15, 23, 42, 0.6);
|
||||||
|
z-index: 30;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-open-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-open-overlay__panel {
|
||||||
|
width: min(940px, 100%);
|
||||||
|
background: linear-gradient(150deg, rgba(255, 255, 255, 0.9), rgba(248, 250, 252, 0.96));
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 38px 42px 40px;
|
||||||
|
box-shadow: 0 32px 90px rgba(15, 23, 42, 0.4);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-open-overlay__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(37, 99, 235, 0.12);
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-open-overlay__timer {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-open-overlay__count {
|
||||||
|
font-size: clamp(72px, 12vw, 120px);
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-open-overlay__unit {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-open-overlay__text {
|
||||||
|
margin: 0 auto;
|
||||||
|
color: #334155;
|
||||||
|
max-width: 700px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-open-overlay__hint {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.posts-load-more {
|
.posts-load-more {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user