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;
|
||||
|
||||
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)
|
||||
async function checkPostStatus(postUrl, allUrlCandidates = []) {
|
||||
try {
|
||||
@@ -880,15 +1040,20 @@ async function persistAlternatePostUrls(postId, urls = []) {
|
||||
}
|
||||
|
||||
// Add post to tracking
|
||||
async function markPostChecked(postId, profileNumber) {
|
||||
async function markPostChecked(postId, profileNumber, options = {}) {
|
||||
try {
|
||||
const ignoreOrder = options && options.ignoreOrder === true;
|
||||
const returnError = options && options.returnError === true;
|
||||
console.log('[FB Tracker] Marking post as checked:', postId, 'Profile:', profileNumber);
|
||||
const response = await backendFetch(`${API_URL}/posts/${postId}/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ profile_number: profileNumber })
|
||||
body: JSON.stringify({
|
||||
profile_number: profileNumber,
|
||||
ignore_order: ignoreOrder
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -898,15 +1063,21 @@ async function markPostChecked(postId, profileNumber) {
|
||||
}
|
||||
|
||||
if (response.status === 409) {
|
||||
console.log('[FB Tracker] Post already checked by this profile');
|
||||
return null;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
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);
|
||||
return null;
|
||||
return returnError
|
||||
? { error: 'Beitrag konnte nicht bestätigt werden.', status: response.status }
|
||||
: null;
|
||||
} catch (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;
|
||||
if (options && options.postElement) {
|
||||
if (options && typeof options.postText === 'string') {
|
||||
postText = options.postText;
|
||||
} else if (options && options.postElement) {
|
||||
try {
|
||||
postText = extractPostText(options.postElement) || null;
|
||||
} catch (error) {
|
||||
@@ -967,6 +1140,13 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options =
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1667,6 +1847,18 @@ async function captureElementScreenshot(element) {
|
||||
const endY = Math.min(documentHeight, elementBottom + 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 {
|
||||
let iteration = 0;
|
||||
let targetScroll = startY;
|
||||
@@ -1723,7 +1915,9 @@ async function captureElementScreenshot(element) {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
window.scrollTo({ top: originalScrollY, left: originalScrollX, behavior: 'auto' });
|
||||
restoreScrollPosition();
|
||||
await delay(0);
|
||||
restoreScrollPosition();
|
||||
}
|
||||
|
||||
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() {
|
||||
try {
|
||||
const banner = document.querySelector('[role="banner"], header[role="banner"]');
|
||||
@@ -1939,6 +2252,9 @@ function extractDeadlineFromPostText(postElement) {
|
||||
|
||||
const extractTimeAfterIndex = (text, index) => {
|
||||
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);
|
||||
if (!timeMatch) {
|
||||
return null;
|
||||
@@ -1950,6 +2266,9 @@ function extractDeadlineFromPostText(postElement) {
|
||||
if (Number.isNaN(hour) || Number.isNaN(minute)) {
|
||||
return null;
|
||||
}
|
||||
if (hour === 24 && minute === 0) {
|
||||
return { hour: 23, minute: 59 };
|
||||
}
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
return null;
|
||||
}
|
||||
@@ -1963,6 +2282,22 @@ function extractDeadlineFromPostText(postElement) {
|
||||
};
|
||||
|
||||
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) {
|
||||
let match;
|
||||
@@ -2404,7 +2739,19 @@ async function renderTrackedStatus({
|
||||
: null;
|
||||
|
||||
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);
|
||||
|
||||
if (isFeedHome && isCurrentProfileDone) {
|
||||
@@ -2434,16 +2781,23 @@ async function renderTrackedStatus({
|
||||
${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 += `
|
||||
<button class="fb-tracker-check-btn" style="
|
||||
<button class="fb-tracker-check-btn" title="${buttonTitle}" style="
|
||||
padding: 4px 12px;
|
||||
background-color: #42b72a;
|
||||
background-color: ${buttonColor};
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
cursor: ${cursorStyle};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
@@ -2524,9 +2878,9 @@ async function renderTrackedStatus({
|
||||
checkBtn.disabled = true;
|
||||
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({
|
||||
container,
|
||||
postElement,
|
||||
@@ -2540,8 +2894,13 @@ async function renderTrackedStatus({
|
||||
});
|
||||
} else {
|
||||
checkBtn.disabled = false;
|
||||
checkBtn.textContent = 'Fehler - Erneut versuchen';
|
||||
checkBtn.style.backgroundColor = '#e74c3c';
|
||||
checkBtn.textContent = '✓ Bestätigen';
|
||||
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;
|
||||
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="
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
@@ -2792,8 +3181,109 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
|
||||
const selectElement = container.querySelector(`#${selectId}`);
|
||||
const deadlineInput = container.querySelector(`#${deadlineId}`);
|
||||
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 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) {
|
||||
const mainLinkButton = document.createElement('button');
|
||||
mainLinkButton.className = 'fb-tracker-mainlink-btn';
|
||||
@@ -2838,11 +3328,15 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
|
||||
await delay(220);
|
||||
|
||||
const deadlineValue = deadlineInput ? deadlineInput.value : '';
|
||||
const payload = await resolveSimilarityPayload();
|
||||
|
||||
const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, {
|
||||
postElement,
|
||||
deadline: deadlineValue,
|
||||
candidates: postUrlData.allCandidates
|
||||
candidates: postUrlData.allCandidates,
|
||||
postText: payload.postText,
|
||||
firstImageHash: payload.firstImageHash,
|
||||
firstImageUrl: payload.firstImageUrl
|
||||
});
|
||||
|
||||
if (result) {
|
||||
@@ -3236,6 +3730,10 @@ let globalPostCounter = 0;
|
||||
|
||||
// Find all Facebook posts on the page
|
||||
function findPosts() {
|
||||
if (maybeRedirectPageReelsToMain()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[FB Tracker] Scanning for posts...');
|
||||
|
||||
const postContainers = findPostContainers();
|
||||
@@ -3343,6 +3841,7 @@ function findPosts() {
|
||||
|
||||
// Initialize
|
||||
console.log('[FB Tracker] Initializing...');
|
||||
maybeRedirectPageReelsToMain();
|
||||
|
||||
// Run multiple times to catch loading posts
|
||||
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 = [
|
||||
'div[role="textbox"]',
|
||||
'[contenteditable="true"]',
|
||||
@@ -4177,7 +4702,7 @@ function extractPostText(postElement) {
|
||||
logPostText('Selector result skipped (inside disallowed region)', selector, makeSnippet(element.innerText || element.textContent || ''));
|
||||
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 elementsToRemove = clone.querySelectorAll(SKIP_TEXT_CONTAINERS_SELECTOR);
|
||||
elementsToRemove.forEach((node) => node.remove());
|
||||
injectEmojiLabels(clone);
|
||||
const cloneText = clone.innerText || clone.textContent || '';
|
||||
fallbackText = cleanCandidate(cloneText);
|
||||
logPostText('Fallback extracted from clone', makeSnippet(fallbackText || cloneText));
|
||||
} catch (error) {
|
||||
const allText = postElement.innerText || postElement.textContent || '';
|
||||
const allText = getTextWithEmojis(postElement);
|
||||
fallbackText = cleanCandidate(allText);
|
||||
logPostText('Fallback extracted from original element', makeSnippet(fallbackText || allText));
|
||||
}
|
||||
@@ -5071,19 +5597,19 @@ async function addAICommentButton(container, postElement) {
|
||||
let postId = container.dataset.postId || '';
|
||||
|
||||
if (postId) {
|
||||
latestData = await markPostChecked(postId, effectiveProfile);
|
||||
latestData = await markPostChecked(postId, effectiveProfile, { ignoreOrder: true });
|
||||
if (!latestData && decodedUrl) {
|
||||
const refreshed = await checkPostStatus(decodedUrl);
|
||||
if (refreshed && 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) {
|
||||
const refreshed = await checkPostStatus(decodedUrl);
|
||||
if (refreshed && 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 mergeModeToggle = document.getElementById('mergeModeToggle');
|
||||
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 SORT_SETTINGS_KEY = 'trackerSortSettings';
|
||||
@@ -313,6 +321,12 @@ const BOOKMARK_WINDOW_DAYS = 28;
|
||||
const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen'];
|
||||
const BOOKMARK_PREFS_KEY = 'trackerBookmarkPreferences';
|
||||
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() {
|
||||
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() {
|
||||
if (!includeExpiredToggle) {
|
||||
return;
|
||||
@@ -345,6 +463,13 @@ function updateIncludeExpiredToggleUI() {
|
||||
}
|
||||
|
||||
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() {
|
||||
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() {
|
||||
try {
|
||||
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) {
|
||||
if (Array.isArray(post.required_profiles) && post.required_profiles.length) {
|
||||
return post.required_profiles
|
||||
@@ -1714,6 +1869,224 @@ function updateFilteredCount(tab, count) {
|
||||
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() {
|
||||
if (loadMoreObserver && observedLoadMoreElement) {
|
||||
loadMoreObserver.unobserve(observedLoadMoreElement);
|
||||
@@ -1828,6 +2201,7 @@ function setTab(tab, { updateUrl = true } = {}) {
|
||||
updateTabInUrl();
|
||||
}
|
||||
renderPosts();
|
||||
maybeAutoOpenPending('tab');
|
||||
}
|
||||
|
||||
function initializeTabFromUrl() {
|
||||
@@ -3011,7 +3385,9 @@ function applyProfileNumber(profileNumber, { fromBackend = false } = {}) {
|
||||
}
|
||||
|
||||
resetVisibleCount();
|
||||
pendingOpenCooldownMap = loadPendingOpenCooldownMap(currentProfile);
|
||||
renderPosts();
|
||||
maybeAutoOpenPending('profile');
|
||||
}
|
||||
|
||||
// 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
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -3164,6 +3576,11 @@ if (sortModeSelect) {
|
||||
sortModeSelect.addEventListener('change', () => {
|
||||
const value = sortModeSelect.value;
|
||||
sortMode = VALID_SORT_MODES.has(value) ? value : DEFAULT_SORT_SETTINGS.mode;
|
||||
const defaultDirection = getDefaultSortDirectionForMode(sortMode);
|
||||
if (defaultDirection) {
|
||||
sortDirection = defaultDirection;
|
||||
updateSortDirectionToggleUI();
|
||||
}
|
||||
saveSortMode();
|
||||
resetVisibleCount();
|
||||
renderPosts();
|
||||
@@ -3201,6 +3618,7 @@ async function fetchPosts({ showLoader = true } = {}) {
|
||||
}
|
||||
|
||||
isFetchingPosts = true;
|
||||
cancelPendingAutoOpen(false);
|
||||
|
||||
try {
|
||||
if (showLoader) {
|
||||
@@ -3218,6 +3636,7 @@ async function fetchPosts({ showLoader = true } = {}) {
|
||||
await normalizeLoadedPostUrls();
|
||||
sortPostsByCreatedAt();
|
||||
renderPosts();
|
||||
maybeAutoOpenPending('load');
|
||||
} catch (error) {
|
||||
if (showLoader) {
|
||||
showError('Fehler beim Laden der Beiträge. Stelle sicher, dass das Backend läuft.');
|
||||
@@ -3487,45 +3906,18 @@ function renderPosts() {
|
||||
updateTabButtons();
|
||||
cleanupLoadMoreObserver();
|
||||
|
||||
const postItems = posts.map((post) => ({
|
||||
post,
|
||||
status: computePostStatus(post)
|
||||
}));
|
||||
const {
|
||||
sortedItems,
|
||||
filteredItems: filteredItemsResult,
|
||||
tabTotalCount,
|
||||
searchActive
|
||||
} = getPostListState();
|
||||
|
||||
const sortedItems = [...postItems].sort(comparePostItems);
|
||||
let filteredItems = filteredItemsResult;
|
||||
const focusCandidateEntry = (!focusHandled && (focusPostIdParam || focusNormalizedUrl))
|
||||
? sortedItems.find((item) => doesPostMatchFocus(item.post))
|
||||
: 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) {
|
||||
const candidateVisibleInCurrentTab = filteredItems.some(({ post }) => doesPostMatchFocus(post));
|
||||
if (!candidateVisibleInCurrentTab) {
|
||||
@@ -3554,6 +3946,7 @@ function renderPosts() {
|
||||
}
|
||||
|
||||
updateFilteredCount(currentTab, filteredItems.length);
|
||||
updatePendingBulkControls(filteredItems.length);
|
||||
|
||||
const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab));
|
||||
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
|
||||
async function bootstrapApp() {
|
||||
const authenticated = await ensureAuthenticated();
|
||||
|
||||
@@ -178,11 +178,43 @@
|
||||
</label>
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="Beiträge durchsuchen...">
|
||||
</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 id="loading" class="loading">Lade Beiträge...</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>
|
||||
|
||||
@@ -1181,6 +1213,36 @@
|
||||
</form>
|
||||
</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 -->
|
||||
<section class="settings-section">
|
||||
<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_auto_hide_enabled: false
|
||||
};
|
||||
let similaritySettings = {
|
||||
text_threshold: 0.85,
|
||||
image_distance_threshold: 6
|
||||
};
|
||||
|
||||
function handleUnauthorized(response) {
|
||||
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) {
|
||||
if (typeof text !== 'string') {
|
||||
return '';
|
||||
@@ -1041,6 +1123,7 @@ async function saveAllSettings(event) {
|
||||
saveSettings(null, { silent: true }),
|
||||
saveHiddenSettings(null, { silent: true }),
|
||||
saveModerationSettings(null, { silent: true }),
|
||||
saveSimilaritySettings(null, { 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
|
||||
Promise.all([
|
||||
loadCredentials(),
|
||||
loadSettings(),
|
||||
loadHiddenSettings(),
|
||||
loadModerationSettings(),
|
||||
loadSimilaritySettings(),
|
||||
loadProfileFriends()
|
||||
]).catch(err => showError(err.message));
|
||||
})();
|
||||
|
||||
135
web/style.css
135
web/style.css
@@ -589,6 +589,141 @@ h1 {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user