From 9745d38995b6ba9c7a39e9af1f61e4502aa0fc6b Mon Sep 17 00:00:00 2001 From: MDeeApp <6595194+MDeeApp@users.noreply.github.com> Date: Sun, 19 Oct 2025 12:14:03 +0200 Subject: [PATCH] Aktueller Stand --- backend/server.js | 300 +++++++++++++++++++++++++--- docker-compose.yml | 60 ++++-- extension/content.js | 459 ++++++++++++++++++++++++++++++------------- web/app.js | 453 +++++++++++++++++++++++++++++++++++++++++- web/index.html | 29 ++- web/style.css | 192 ++++++++++++++++++ 6 files changed, 1304 insertions(+), 189 deletions(-) diff --git a/backend/server.js b/backend/server.js index c02feb4..5d04a2e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -19,7 +19,7 @@ const DEFAULT_PROFILE_NAMES = { const PROFILE_SCOPE_COOKIE = 'fb_tracker_scope'; const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days const FACEBOOK_TRACKING_PARAM_PREFIXES = ['__cft__', '__tn__', '__eep__', 'mibextid']; -const SEARCH_POST_HIDE_THRESHOLD = 3; +const SEARCH_POST_HIDE_THRESHOLD = 2; const SEARCH_POST_RETENTION_DAYS = 90; const screenshotDir = path.join(__dirname, 'data', 'screenshots'); @@ -277,10 +277,14 @@ function normalizeFacebookPostUrl(rawValue) { const cleanedParams = new URLSearchParams(); parsed.searchParams.forEach((paramValue, paramKey) => { const lowerKey = paramKey.toLowerCase(); - if (FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix)) || lowerKey === 'set' || lowerKey === 'comment_id') { - return; - } - if (lowerKey === 'hoisted_section_header_type') { + const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit'; + if ( + FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix)) + || lowerKey === 'set' + || lowerKey === 'comment_id' + || lowerKey === 'hoisted_section_header_type' + || isSingleUnitParam + ) { return; } cleanedParams.append(paramKey, paramValue); @@ -394,6 +398,34 @@ db.exec(` ); `); +db.exec(` + CREATE TABLE IF NOT EXISTS post_urls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + is_primary INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE + ); +`); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_post_urls_post_id + ON post_urls(post_id); +`); + +db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_post_urls_primary + ON post_urls(post_id) + WHERE is_primary = 1; +`); + +db.prepare(` + INSERT OR IGNORE INTO post_urls (post_id, url, is_primary) + SELECT id, url, 1 + FROM posts +`).run(); + db.exec(` CREATE TABLE IF NOT EXISTS checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1132,13 +1164,59 @@ function cleanupExpiredSearchPosts() { } } +function expandPhotoUrlHostVariants(url) { + if (typeof url !== 'string' || !url) { + return []; + } + + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + if (!hostname.endsWith('facebook.com')) { + return []; + } + + const pathname = parsed.pathname.toLowerCase(); + if (!pathname.startsWith('/photo')) { + return []; + } + + const protocol = parsed.protocol || 'https:'; + const search = parsed.search || ''; + const hosts = ['www.facebook.com', 'facebook.com', 'm.facebook.com']; + const variants = []; + + for (const candidateHost of hosts) { + if (candidateHost === hostname) { + continue; + } + const candidateUrl = `${protocol}//${candidateHost}${parsed.pathname}${search}`; + const normalized = normalizeFacebookPostUrl(candidateUrl); + if (normalized && normalized !== url && !variants.includes(normalized)) { + variants.push(normalized); + } + } + + return variants; + } catch (error) { + return []; + } +} + function collectNormalizedFacebookUrls(primaryUrl, candidates = []) { const normalized = []; - const pushNormalized = (value) => { + const pushNormalized = (value, expandVariants = true) => { const normalizedUrl = normalizeFacebookPostUrl(value); if (normalizedUrl && !normalized.includes(normalizedUrl)) { normalized.push(normalizedUrl); + + if (expandVariants) { + const photoVariants = expandPhotoUrlHostVariants(normalizedUrl); + for (const variant of photoVariants) { + pushNormalized(variant, false); + } + } } }; @@ -1155,6 +1233,105 @@ function collectNormalizedFacebookUrls(primaryUrl, candidates = []) { return normalized; } +function collectPostAlternateUrls(primaryUrl, candidates = []) { + const normalizedPrimary = normalizeFacebookPostUrl(primaryUrl); + if (!normalizedPrimary) { + return []; + } + + const normalized = collectNormalizedFacebookUrls(normalizedPrimary, candidates); + return normalized.filter(url => url !== normalizedPrimary); +} + +const insertPostUrlStmt = db.prepare(` + INSERT OR IGNORE INTO post_urls (post_id, url, is_primary) + VALUES (?, ?, ?) +`); + +const setPrimaryPostUrlStmt = db.prepare(` + UPDATE post_urls + SET is_primary = CASE WHEN url = ? THEN 1 ELSE 0 END + WHERE post_id = ? +`); + +const selectPostByPrimaryUrlStmt = db.prepare('SELECT * FROM posts WHERE url = ?'); +const selectPostByAlternateUrlStmt = db.prepare(` + SELECT p.* + FROM post_urls pu + JOIN posts p ON p.id = pu.post_id + WHERE pu.url = ? + LIMIT 1 +`); +const selectPostIdByPrimaryUrlStmt = db.prepare('SELECT id FROM posts WHERE url = ?'); +const selectPostIdByAlternateUrlStmt = db.prepare('SELECT post_id FROM post_urls WHERE url = ?'); +const selectAlternateUrlsForPostStmt = db.prepare(` + SELECT url + FROM post_urls + WHERE post_id = ? + AND is_primary = 0 + ORDER BY created_at ASC +`); + +function storePostUrls(postId, primaryUrl, additionalUrls = []) { + if (!postId || !primaryUrl) { + return; + } + + const normalizedPrimary = normalizeFacebookPostUrl(primaryUrl); + if (!normalizedPrimary) { + return; + } + + insertPostUrlStmt.run(postId, normalizedPrimary, 1); + setPrimaryPostUrlStmt.run(normalizedPrimary, postId); + + if (Array.isArray(additionalUrls)) { + for (const candidate of additionalUrls) { + const normalized = normalizeFacebookPostUrl(candidate); + if (!normalized || normalized === normalizedPrimary) { + continue; + } + insertPostUrlStmt.run(postId, normalized, 0); + } + } +} + +function findPostIdByUrl(normalizedUrl) { + if (!normalizedUrl) { + return null; + } + + const primaryRow = selectPostIdByPrimaryUrlStmt.get(normalizedUrl); + if (primaryRow && primaryRow.id) { + return primaryRow.id; + } + + const alternateRow = selectPostIdByAlternateUrlStmt.get(normalizedUrl); + if (alternateRow && alternateRow.post_id) { + return alternateRow.post_id; + } + + return null; +} + +function findPostByUrl(normalizedUrl) { + if (!normalizedUrl) { + return null; + } + + const primary = selectPostByPrimaryUrlStmt.get(normalizedUrl); + if (primary) { + return primary; + } + + const alternate = selectPostByAlternateUrlStmt.get(normalizedUrl); + if (alternate) { + return alternate; + } + + return null; +} + function removeSearchSeenEntries(urls) { if (!Array.isArray(urls) || urls.length === 0) { return; @@ -1191,9 +1368,6 @@ const updateSearchSeenStmt = db.prepare(` SET seen_count = ?, manually_hidden = ?, last_seen_at = CURRENT_TIMESTAMP WHERE url = ? `); -const deleteSearchSeenStmt = db.prepare('DELETE FROM search_seen_posts WHERE url = ?'); -const selectTrackedPostStmt = db.prepare('SELECT id FROM posts WHERE url = ?'); - const checkIndexes = db.prepare("PRAGMA index_list('checks')").all(); for (const idx of checkIndexes) { if (idx.unique) { @@ -1274,6 +1448,9 @@ function mapPostRow(post) { checked_at: sqliteTimestampToUTC(status.checked_at) })); + const alternateUrlRows = selectAlternateUrlsForPostStmt.all(post.id); + const alternateUrls = alternateUrlRows.map(row => row.url); + return { ...post, created_at: sqliteTimestampToUTC(post.created_at), @@ -1289,7 +1466,8 @@ function mapPostRow(post) { created_by_profile: creatorProfile, created_by_profile_name: creatorProfile ? getProfileName(creatorProfile) : null, created_by_name: creatorName, - deadline_at: post.deadline_at || null + deadline_at: post.deadline_at || null, + alternate_urls: alternateUrls }; } @@ -1321,12 +1499,16 @@ app.get('/api/posts/by-url', (req, res) => { return res.status(400).json({ error: 'URL parameter must be a valid Facebook link' }); } - const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl); - + const post = findPostByUrl(normalizedUrl); if (!post) { return res.json(null); } + const alternates = collectPostAlternateUrls(post.url, [normalizedUrl]); + if (alternates.length) { + storePostUrls(post.id, post.url, alternates); + } + res.json(mapPostRow(post)); } catch (error) { res.status(500).json({ error: error.message }); @@ -1344,16 +1526,19 @@ app.post('/api/search-posts', (req, res) => { cleanupExpiredSearchPosts(); - let isTracked = false; + let trackedPost = null; for (const candidate of normalizedUrls) { - const tracked = selectTrackedPostStmt.get(candidate); - if (tracked) { - isTracked = true; - deleteSearchSeenStmt.run(candidate); + const found = findPostByUrl(candidate); + if (found) { + trackedPost = found; + break; } } - if (isTracked) { + if (trackedPost) { + const alternateUrls = collectPostAlternateUrls(trackedPost.url, normalizedUrls); + storePostUrls(trackedPost.id, trackedPost.url, alternateUrls); + removeSearchSeenEntries([trackedPost.url, ...alternateUrls]); return res.json({ seen_count: 0, should_hide: false, tracked: true }); } @@ -1555,6 +1740,7 @@ app.post('/api/posts', (req, res) => { } = req.body; const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count); + const alternateUrlsInput = Array.isArray(req.body.alternate_urls) ? req.body.alternate_urls : []; const normalizedUrl = normalizeFacebookPostUrl(url); @@ -1591,7 +1777,9 @@ app.post('/api/posts', (req, res) => { const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id); - removeSearchSeenEntries([normalizedUrl]); + const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateUrlsInput); + storePostUrls(id, normalizedUrl, alternateUrls); + removeSearchSeenEntries([normalizedUrl, ...alternateUrls]); res.json(mapPostRow(post)); } catch (error) { @@ -1607,6 +1795,12 @@ app.put('/api/posts/:postId', (req, res) => { try { const { postId } = req.params; const { target_count, title, created_by_profile, created_by_name, deadline_at, url } = req.body || {}; + const alternateUrlsInput = Array.isArray(req.body && req.body.alternate_urls) ? req.body.alternate_urls : []; + const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + + if (!existingPost) { + return res.status(404).json({ error: 'Post not found' }); + } const updates = []; const params = []; @@ -1672,9 +1866,8 @@ app.put('/api/posts/:postId', (req, res) => { params.push(postId); const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`); - let result; try { - result = stmt.run(...params); + stmt.run(...params); } catch (error) { if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { return res.status(409).json({ error: 'Post with this URL already exists' }); @@ -1682,18 +1875,25 @@ app.put('/api/posts/:postId', (req, res) => { throw error; } - if (result.changes === 0) { - return res.status(404).json({ error: 'Post not found' }); - } - recalcCheckedCount(postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); - if (normalizedUrlForCleanup) { - removeSearchSeenEntries([normalizedUrlForCleanup]); + const alternateCandidates = [...alternateUrlsInput]; + if (existingPost.url && existingPost.url !== updatedPost.url) { + alternateCandidates.push(existingPost.url); } + const alternateUrls = collectPostAlternateUrls(updatedPost.url, alternateCandidates); + storePostUrls(updatedPost.id, updatedPost.url, alternateUrls); + + const cleanupUrls = new Set([updatedPost.url]); + alternateUrls.forEach(urlValue => cleanupUrls.add(urlValue)); + if (normalizedUrlForCleanup && normalizedUrlForCleanup !== updatedPost.url) { + cleanupUrls.add(normalizedUrlForCleanup); + } + removeSearchSeenEntries(Array.from(cleanupUrls)); + res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); @@ -1785,6 +1985,32 @@ app.post('/api/posts/:postId/check', (req, res) => { } }); +app.post('/api/posts/:postId/urls', (req, res) => { + try { + const { postId } = req.params; + const { urls } = req.body || {}; + + const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + const candidateList = Array.isArray(urls) ? urls : []; + const alternateUrls = collectPostAlternateUrls(post.url, candidateList); + storePostUrls(post.id, post.url, alternateUrls); + removeSearchSeenEntries([post.url, ...alternateUrls]); + + const storedAlternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url); + res.json({ + success: true, + primary_url: post.url, + alternate_urls: storedAlternates + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Check by URL (for web interface auto-check) app.post('/api/check-by-url', (req, res) => { try { @@ -1799,11 +2025,15 @@ app.post('/api/check-by-url', (req, res) => { return res.status(400).json({ error: 'URL must be a valid Facebook link' }); } - const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl); + const post = findPostByUrl(normalizedUrl); if (!post) { return res.status(404).json({ error: 'Post not found' }); } + const alternateUrls = collectPostAlternateUrls(post.url, [normalizedUrl]); + storePostUrls(post.id, post.url, alternateUrls); + removeSearchSeenEntries([post.url, ...alternateUrls]); + // Check if deadline has passed if (post.deadline_at) { const deadline = new Date(post.deadline_at); @@ -1970,8 +2200,8 @@ app.patch('/api/posts/:postId', (req, res) => { const { url, is_successful } = req.body; // Check if post exists - const post = db.prepare('SELECT id FROM posts WHERE id = ?').get(postId); - if (!post) { + const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + if (!existingPost) { return res.status(404).json({ error: 'Post not found' }); } @@ -1989,7 +2219,15 @@ app.patch('/api/posts/:postId', (req, res) => { // Update URL db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(normalizedUrl, postId); - removeSearchSeenEntries([normalizedUrl]); + + const alternateCandidates = []; + if (existingPost.url && existingPost.url !== normalizedUrl) { + alternateCandidates.push(existingPost.url); + } + + const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateCandidates); + storePostUrls(postId, normalizedUrl, alternateUrls); + removeSearchSeenEntries([normalizedUrl, ...alternateUrls]); return res.json({ success: true, url: normalizedUrl }); } diff --git a/docker-compose.yml b/docker-compose.yml index 04e9c2a..00ae598 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,21 @@ -version: '3.8' - -services: - backend: - build: ./backend - container_name: fb-tracker-backend - ports: - - "3001:3000" - volumes: - - ./backend/server.js:/app/server.js:ro - - db-data:/app/data - environment: - - NODE_ENV=production - - PORT=3000 - labels: - - com.centurylinklabs.watchtower.enable=false - restart: unless-stopped - +version: '3.8' + +services: + backend: + build: ./backend + container_name: fb-tracker-backend + ports: + - "3001:3000" + volumes: + - ./backend/server.js:/app/server.js:ro + - /opt/docker/posttracker/data:/app/data + environment: + - NODE_ENV=production + - PORT=3000 + labels: + - com.centurylinklabs.watchtower.enable=false + restart: unless-stopped + web: build: ./web container_name: fb-tracker-web @@ -24,6 +24,28 @@ services: depends_on: - backend restart: unless-stopped + labels: + - com.centurylinklabs.watchtower.enable=false + + sqlite-web: + image: coleifer/sqlite-web + container_name: fb-sqlite-web + command: + - sqlite_web + - /data/tracker.db + - --host + - 0.0.0.0 + - --port + - "8080" + ports: + - "8083:8080" + volumes: + - /opt/docker/posttracker/data:/data + depends_on: + - backend + restart: unless-stopped + labels: + - com.centurylinklabs.watchtower.enable=false volumes: - db-data: \ No newline at end of file + db-data: diff --git a/extension/content.js b/extension/content.js index 3dc7945..3fa90f1 100644 --- a/extension/content.js +++ b/extension/content.js @@ -486,6 +486,49 @@ function getPostUrl(postElement, postNum = '?') { return { url: '', allCandidates: [] }; } +function expandPhotoUrlHostVariants(url) { + if (typeof url !== 'string' || !url) { + return []; + } + + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + if (!hostname.endsWith('facebook.com')) { + return []; + } + + const pathname = parsed.pathname.toLowerCase(); + if (!pathname.startsWith('/photo')) { + return []; + } + + const search = parsed.search || ''; + const protocol = parsed.protocol || 'https:'; + const hosts = ['www.facebook.com', 'facebook.com', 'm.facebook.com']; + const variants = []; + + for (const candidateHost of hosts) { + if (candidateHost === hostname) { + continue; + } + const candidateUrl = `${protocol}//${candidateHost}${parsed.pathname}${search}`; + const normalizedVariant = normalizeFacebookPostUrl(candidateUrl); + if ( + normalizedVariant + && normalizedVariant !== url + && !variants.includes(normalizedVariant) + ) { + variants.push(normalizedVariant); + } + } + + return variants; + } catch (error) { + return []; + } +} + // Check if post is already tracked (checks all URL candidates to avoid duplicates) async function checkPostStatus(postUrl, allUrlCandidates = []) { try { @@ -507,13 +550,30 @@ async function checkPostStatus(postUrl, allUrlCandidates = []) { } } - console.log('[FB Tracker] Checking post status for URLs:', urlsToCheck); + const photoHostVariants = []; + for (const candidateUrl of urlsToCheck) { + const variants = expandPhotoUrlHostVariants(candidateUrl); + for (const variant of variants) { + if (!urlsToCheck.includes(variant) && !photoHostVariants.includes(variant)) { + photoHostVariants.push(variant); + } + } + } + + const allUrlsToCheck = photoHostVariants.length + ? urlsToCheck.concat(photoHostVariants) + : urlsToCheck; + + if (photoHostVariants.length) { + console.log('[FB Tracker] Added photo host variants for status check:', photoHostVariants); + } + console.log('[FB Tracker] Checking post status for URLs:', allUrlsToCheck); let foundPost = null; let foundUrl = null; // Check each URL - for (const url of urlsToCheck) { + for (const url of allUrlsToCheck) { const response = await backendFetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(url)}`); if (response.ok) { @@ -544,10 +604,14 @@ async function checkPostStatus(postUrl, allUrlCandidates = []) { } if (foundPost) { + const urlsForPersistence = allUrlsToCheck.filter(urlValue => urlValue && urlValue !== foundPost.url); + if (urlsForPersistence.length) { + await persistAlternatePostUrls(foundPost.id, urlsForPersistence); + } return foundPost; } - console.log('[FB Tracker] Post not tracked yet (checked', urlsToCheck.length, 'URLs)'); + console.log('[FB Tracker] Post not tracked yet (checked', allUrlsToCheck.length, 'URLs)'); return null; } catch (error) { console.error('[FB Tracker] Error checking post status:', error); @@ -615,6 +679,29 @@ async function updatePostUrl(postId, newUrl) { } } +async function persistAlternatePostUrls(postId, urls = []) { + if (!postId || !Array.isArray(urls) || urls.length === 0) { + return; + } + + const uniqueUrls = Array.from(new Set(urls.filter(url => typeof url === 'string' && url.trim()))); + if (!uniqueUrls.length) { + return; + } + + try { + await backendFetch(`${API_URL}/posts/${postId}/urls`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ urls: uniqueUrls }) + }); + } catch (error) { + console.debug('[FB Tracker] Persisting alternate URLs failed:', error); + } +} + // Add post to tracking async function markPostChecked(postId, profileNumber) { try { @@ -663,6 +750,8 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options = } } + const alternateCandidates = Array.isArray(options && options.candidates) ? options.candidates : []; + const normalizedUrl = normalizeFacebookPostUrl(postUrl); if (!normalizedUrl) { console.error('[FB Tracker] Post URL konnte nicht normalisiert werden:', postUrl); @@ -676,6 +765,10 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options = created_by_profile: profileNumber }; + if (alternateCandidates.length) { + payload.alternate_urls = alternateCandidates; + } + if (createdByName) { payload.created_by_name = createdByName; } @@ -697,11 +790,7 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options = console.log('[FB Tracker] Post added successfully:', data); if (data && data.id) { - const checkedData = await markPostChecked(data.id, profileNumber); await captureAndUploadScreenshot(data.id, options.postElement || null); - if (checkedData) { - return checkedData; - } } return data; @@ -1690,6 +1779,7 @@ function normalizeFacebookPostUrl(rawValue) { const cleanedParams = new URLSearchParams(); parsed.searchParams.forEach((paramValue, paramKey) => { const lowerKey = paramKey.toLowerCase(); + const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit'; if ( lowerKey.startsWith('__cft__') || lowerKey.startsWith('__tn__') @@ -1698,6 +1788,7 @@ function normalizeFacebookPostUrl(rawValue) { || lowerKey === 'set' || lowerKey === 'comment_id' || lowerKey === 'hoisted_section_header_type' + || isSingleUnitParam ) { return; } @@ -1721,6 +1812,127 @@ function normalizeFacebookPostUrl(rawValue) { return formatted.replace(/[?&]$/, ''); } +async function renderTrackedStatus({ + container, + postElement, + postData, + profileNumber, + isFeedHome, + isDialogContext, + manualHideInfo, + encodedUrl, + postNum +}) { + if (!postData) { + container.innerHTML = ''; + return { hidden: false }; + } + + if (postData.id) { + container.dataset.postId = postData.id; + } + + const checks = Array.isArray(postData.checks) ? postData.checks : []; + const checkedCount = postData.checked_count ?? checks.length; + const targetTotal = postData.target_count || checks.length || 0; + const statusText = `${checkedCount}/${targetTotal}`; + const completed = checkedCount >= targetTotal && targetTotal > 0; + const lastCheck = checks.length + ? new Date(checks[checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) + : null; + + const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false; + const canCurrentProfileCheck = postData.next_required_profile === profileNumber; + const isCurrentProfileDone = checks.some(check => check.profile_number === profileNumber); + + if (isFeedHome && isCurrentProfileDone) { + if (isDialogContext) { + console.log('[FB Tracker] Post #' + postNum + ' - Would hide on feed (already checked by current profile) but skipping in dialog context'); + } else { + console.log('[FB Tracker] Post #' + postNum + ' - Hidden on feed (already checked by current profile)'); + hidePostElement(postElement); + processedPostUrls.set(encodedUrl, { + element: postElement, + createdAt: Date.now(), + hidden: true, + searchSeenCount: manualHideInfo && typeof manualHideInfo.seen_count === 'number' + ? manualHideInfo.seen_count + : null + }); + return { hidden: true }; + } + } + + const expiredText = isExpired ? ' ⚠️ ABGELAUFEN' : ''; + + let statusHtml = ` +
+ Tracker: ${statusText}${completed ? ' ✓' : ''}${expiredText} +
+ ${lastCheck ? `
Letzte: ${lastCheck}
` : ''} + `; + + if (canCurrentProfileCheck && !isExpired && !completed) { + statusHtml += ` + + `; + } else if (isCurrentProfileDone) { + statusHtml += ` +
+ ✓ Von dir bestätigt +
+ `; + } + + container.innerHTML = statusHtml; + + await addAICommentButton(container, postElement); + + const checkBtn = container.querySelector('.fb-tracker-check-btn'); + if (checkBtn) { + checkBtn.addEventListener('click', async () => { + checkBtn.disabled = true; + checkBtn.textContent = 'Wird bestätigt...'; + + const result = await markPostChecked(postData.id, profileNumber); + + if (result) { + await renderTrackedStatus({ + container, + postElement, + postData: result, + profileNumber, + isFeedHome, + isDialogContext, + manualHideInfo, + encodedUrl, + postNum + }); + } else { + checkBtn.disabled = false; + checkBtn.textContent = 'Fehler - Erneut versuchen'; + checkBtn.style.backgroundColor = '#e74c3c'; + } + }); + } + + console.log('[FB Tracker] Showing status:', statusText); + return { hidden: false }; +} + // Create the tracking UI async function createTrackerUI(postElement, buttonBar, postNum = '?', options = {}) { // Normalize to top-level post container if nested element passed @@ -1789,6 +2001,8 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = container.id = 'fb-tracker-ui-post-' + postNum; container.setAttribute('data-post-num', postNum); container.setAttribute('data-post-url', encodedUrl); + container.dataset.isFeedHome = isFeedHome ? '1' : '0'; + container.dataset.isDialogContext = isDialogContext ? '1' : '0'; container.style.cssText = ` padding: 6px 12px; background-color: #f0f2f5; @@ -1894,111 +2108,21 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = } if (postData) { - const checkedCount = postData.checked_count ?? (Array.isArray(postData.checks) ? postData.checks.length : 0); - const statusText = `${checkedCount}/${postData.target_count}`; - const completed = checkedCount >= postData.target_count; - const lastCheck = Array.isArray(postData.checks) && postData.checks.length - ? new Date(postData.checks[postData.checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) - : null; + const renderResult = await renderTrackedStatus({ + container, + postElement, + postData, + profileNumber, + isFeedHome, + isDialogContext, + manualHideInfo, + encodedUrl, + postNum + }); - // Check if deadline has passed - const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false; - const expiredText = isExpired ? ' ⚠️ ABGELAUFEN' : ''; - - // Check if current profile can check this post - const canCurrentProfileCheck = postData.next_required_profile === profileNumber; - const isCurrentProfileDone = Array.isArray(postData.checks) && postData.checks.some(check => check.profile_number === profileNumber); - - if (isFeedHome && isCurrentProfileDone) { - if (isDialogContext) { - console.log('[FB Tracker] Post #' + postNum + ' - Would hide on feed (already checked by current profile) but skipping in dialog context'); - } else { - console.log('[FB Tracker] Post #' + postNum + ' - Hidden on feed (already checked by current profile)'); - hidePostElement(postElement); - processedPostUrls.set(encodedUrl, { - element: postElement, - createdAt: Date.now(), - hidden: true, - searchSeenCount: manualHideInfo && typeof manualHideInfo.seen_count === 'number' ? manualHideInfo.seen_count : null - }); - return; - } + if (renderResult && renderResult.hidden) { + return; } - - let statusHtml = ` -
- Tracker: ${statusText}${completed ? ' ✓' : ''}${expiredText} -
- ${lastCheck ? `
Letzte: ${lastCheck}
` : ''} - `; - - // Add check button if current profile can check and not expired - if (canCurrentProfileCheck && !isExpired && !completed) { - statusHtml += ` - - `; - } else if (isCurrentProfileDone) { - statusHtml += ` -
- ✓ Von dir bestätigt -
- `; - } - - container.innerHTML = statusHtml; - - // Add AI button - await addAICommentButton(container, postElement); - - // Add event listener for check button - const checkBtn = container.querySelector('.fb-tracker-check-btn'); - if (checkBtn) { - checkBtn.addEventListener('click', async () => { - checkBtn.disabled = true; - checkBtn.textContent = 'Wird bestätigt...'; - - const result = await markPostChecked(postData.id, profileNumber); - - if (result) { - const newCheckedCount = result.checked_count ?? checkedCount + 1; - const newStatusText = `${newCheckedCount}/${postData.target_count}`; - const newCompleted = newCheckedCount >= postData.target_count; - const newLastCheck = new Date().toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }); - - container.innerHTML = ` -
- Tracker: ${newStatusText}${newCompleted ? ' ✓' : ''} -
-
Letzte: ${newLastCheck}
-
- ✓ Von dir bestätigt -
- `; - - // Re-add AI button after update - await addAICommentButton(container, postElement); - } else { - checkBtn.disabled = false; - checkBtn.textContent = 'Fehler - Erneut versuchen'; - checkBtn.style.backgroundColor = '#e74c3c'; - } - }); - } - - console.log('[FB Tracker] Showing status:', statusText); } else { // Post not tracked - show add UI const selectId = `tracker-select-${Date.now()}`; @@ -2072,37 +2196,28 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, { postElement, - deadline: deadlineValue + deadline: deadlineValue, + candidates: postUrlData.allCandidates }); if (result) { - const checks = Array.isArray(result.checks) ? result.checks : []; - const checkedCount = typeof result.checked_count === 'number' ? result.checked_count : checks.length; - const targetTotal = result.target_count || targetCount; - const statusText = `${checkedCount}/${targetTotal}`; - const completed = checkedCount >= targetTotal; - const lastCheck = checks.length ? new Date(checks[checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : null; + const renderOutcome = await renderTrackedStatus({ + container, + postElement, + postData: result, + profileNumber, + isFeedHome, + isDialogContext, + manualHideInfo, + encodedUrl, + postNum + }); - let statusHtml = ` -
- Tracker: ${statusText}${completed ? ' ✓' : ''} -
- `; - - if (lastCheck) { - statusHtml += ` -
- Letzte: ${lastCheck} -
- `; + if (renderOutcome && renderOutcome.hidden) { + return; } - container.innerHTML = statusHtml; - if (deadlineInput) { - deadlineInput.value = ''; - } - - await addAICommentButton(container, postElement); + return; } else { // Error addButton.disabled = false; @@ -3667,6 +3782,77 @@ async function addAICommentButton(container, postElement) { window.removeEventListener('resize', repositionDropdown); }; + const getDecodedPostUrl = () => { + const raw = encodedPostUrl || (container && container.getAttribute('data-post-url')); + if (!raw) { + return null; + } + try { + return decodeURIComponent(raw); + } catch (error) { + console.warn('[FB Tracker] Konnte Post-URL nicht dekodieren:', error); + return null; + } + }; + + const confirmParticipationAfterAI = async (profileNumber) => { + try { + if (!container) { + return; + } + + const effectiveProfile = profileNumber || await getProfileNumber(); + const decodedUrl = getDecodedPostUrl(); + const isFeedHomeFlag = container.dataset.isFeedHome === '1'; + const isDialogFlag = container.dataset.isDialogContext === '1'; + const postNumValue = container.getAttribute('data-post-num') || '?'; + const encodedUrlValue = container.getAttribute('data-post-url') || ''; + + let latestData = null; + let postId = container.dataset.postId || ''; + + if (postId) { + latestData = await markPostChecked(postId, effectiveProfile); + if (!latestData && decodedUrl) { + const refreshed = await checkPostStatus(decodedUrl); + if (refreshed && refreshed.id) { + container.dataset.postId = refreshed.id; + latestData = await markPostChecked(refreshed.id, effectiveProfile) || 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; + } + } + + if (!latestData && decodedUrl) { + const fallbackStatus = await checkPostStatus(decodedUrl); + if (fallbackStatus) { + latestData = fallbackStatus; + } + } + + if (latestData) { + await renderTrackedStatus({ + container, + postElement, + postData: latestData, + profileNumber: effectiveProfile, + isFeedHome: isFeedHomeFlag, + isDialogContext: isDialogFlag, + manualHideInfo: null, + encodedUrl: encodedUrlValue, + postNum: postNumValue + }); + } + } catch (error) { + console.error('[FB Tracker] Auto-confirm nach AI fehlgeschlagen:', error); + } + }; + const handleOutsideClick = (event) => { if (!wrapper.contains(event.target) && !dropdown.contains(event.target)) { closeDropdown(); @@ -4134,6 +4320,7 @@ async function addAICommentButton(container, postElement) { throwIfCancelled(); showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info'); restoreIdle('📋 Kopiert', 2000); + await confirmParticipationAfterAI(profileNumber); return; } @@ -4148,11 +4335,13 @@ async function addAICommentButton(container, postElement) { if (success) { showToast('✓ Kommentar wurde eingefügt', 'success'); restoreIdle('✓ Eingefügt', 2000); + await confirmParticipationAfterAI(profileNumber); } else { await navigator.clipboard.writeText(comment); throwIfCancelled(); showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info'); restoreIdle('📋 Kopiert', 2000); + await confirmParticipationAfterAI(profileNumber); } } catch (error) { const cancelled = aiContext.cancelled || isCancellationError(error); diff --git a/web/app.js b/web/app.js index 003be28..f08fa10 100644 --- a/web/app.js +++ b/web/app.js @@ -25,7 +25,7 @@ const PROFILE_NAMES = { 4: 'Profil 4', 5: 'Profil 5' }; - + function apiFetch(url, options = {}) { const config = { ...options, @@ -68,13 +68,26 @@ const autoRefreshToggle = document.getElementById('autoRefreshToggle'); const autoRefreshIntervalSelect = document.getElementById('autoRefreshInterval'); const sortModeSelect = document.getElementById('sortMode'); const sortDirectionToggle = document.getElementById('sortDirectionToggle'); +const bookmarkPanelToggle = document.getElementById('bookmarkPanelToggle'); +const bookmarkPanel = document.getElementById('bookmarkPanel'); +const bookmarkPanelClose = document.getElementById('bookmarkPanelClose'); +const bookmarksList = document.getElementById('bookmarksList'); +const bookmarkForm = document.getElementById('bookmarkForm'); +const bookmarkNameInput = document.getElementById('bookmarkName'); +const bookmarkQueryInput = document.getElementById('bookmarkQuery'); +const bookmarkCancelBtn = document.getElementById('bookmarkCancelBtn'); const REFRESH_SETTINGS_KEY = 'trackerRefreshSettings'; const SORT_SETTINGS_KEY = 'trackerSortSettings'; const SORT_SETTINGS_LEGACY_KEY = 'trackerSortMode'; -const FACEBOOK_TRACKING_PARAMS = ['__cft__', '__tn__', '__eep__', 'mibextid']; +const FACEBOOK_TRACKING_PARAMS = ['__cft__', '__tn__', '__eep__', 'mibextid', 'set', 'comment_id', 'hoisted_section_header_type']; const VALID_SORT_MODES = new Set(['created', 'deadline', 'smart', 'lastCheck', 'lastChange']); const DEFAULT_SORT_SETTINGS = { mode: 'created', direction: 'desc' }; +const BOOKMARKS_STORAGE_KEY = 'trackerSearchBookmarks'; +const BOOKMARKS_BASE_URL = 'https://www.facebook.com/search/top'; +const BOOKMARK_WINDOW_DAYS = 28; +const DEFAULT_BOOKMARKS = []; +const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen']; let autoRefreshTimer = null; let autoRefreshSettings = { @@ -89,6 +102,8 @@ let manualPostEditingId = null; let manualPostModalLastFocus = null; let manualPostModalPreviousOverflow = ''; let activeDeadlinePicker = null; +let bookmarkPanelVisible = false; +let bookmarkOutsideHandler = null; const INITIAL_POST_LIMIT = 10; const POST_LOAD_INCREMENT = 10; @@ -222,6 +237,429 @@ function persistSortStorage(storage) { } } +function normalizeCustomBookmark(entry) { + if (!entry || typeof entry !== 'object') { + return null; + } + + const query = typeof entry.query === 'string' ? entry.query.trim() : ''; + if (!query) { + return null; + } + + const label = typeof entry.label === 'string' && entry.label.trim() ? entry.label.trim() : query; + const id = typeof entry.id === 'string' && entry.id ? entry.id : `custom-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + + return { + id, + label, + query, + type: 'custom' + }; +} + +function loadCustomBookmarks() { + try { + const raw = localStorage.getItem(BOOKMARKS_STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.map(normalizeCustomBookmark).filter(Boolean); + } catch (error) { + console.warn('Konnte Bookmarks nicht laden:', error); + return []; + } +} + +function saveCustomBookmarks(bookmarks) { + try { + const sanitized = Array.isArray(bookmarks) + ? bookmarks.map(normalizeCustomBookmark).filter(Boolean) + : []; + localStorage.setItem(BOOKMARKS_STORAGE_KEY, JSON.stringify(sanitized)); + } catch (error) { + console.warn('Konnte Bookmarks nicht speichern:', error); + } +} + +function formatFacebookDateParts(date) { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const monthLabel = `${year}-${month}`; + const dayLabel = `${year}-${month}-${day}`; + return { + year: String(year), + monthLabel, + dayLabel + }; +} + +function buildBookmarkFiltersParam() { +const y = String(new Date().getFullYear()); // "2025" + + // start_month = Monat von (heute - BOOKMARK_WINDOW_DAYS), auf Monatsanfang (ohne Padding) + const windowAgo = new Date(); + windowAgo.setDate(windowAgo.getDate() - BOOKMARK_WINDOW_DAYS); + const startMonthNum = windowAgo.getMonth() + 1; // 1..12 + const startMonthLabel = `${y}-${startMonthNum}`; // z.B. "2025-9" + const startDayLabel = `${startMonthLabel}-1`; // z.B. "2025-9-1" + + // Ende = Jahresende (ohne Padding), Jahre immer aktuelles Jahr als String + const endMonthLabel = `${y}-12`; + const endDayLabel = `${y}-12-31`; + + // Reihenfolge wie gewünscht: top_tab zuerst, dann rp_creation_time + const filtersPayload = { + 'top_tab_recent_posts:0': JSON.stringify({ + name: 'top_tab_recent_posts', + args: '' + }), + 'rp_creation_time:0': JSON.stringify({ + name: 'creation_time', + args: JSON.stringify({ + start_year: y, // als String + start_month: startMonthLabel, + end_year: y, // als String + end_month: endMonthLabel, + start_day: startDayLabel, + end_day: endDayLabel + }) + }) + }; + + const serialized = JSON.stringify(filtersPayload); + + // Rohes Base64 zurückgeben (kein encodeURIComponent!) + if (typeof window !== 'undefined' && typeof window.btoa === 'function') { + return window.btoa(serialized); + } else if (typeof btoa === 'function') { + return btoa(serialized); + } else if (typeof Buffer !== 'undefined') { + return Buffer.from(serialized, 'utf8').toString('base64'); + } else { + console.warn('Base64 nicht verfügbar, gebe JSON zurück (wird von URLSearchParams encodet).'); + return serialized; + } +} +/* +function buildBookmarkFiltersParam() { + const y = String(new Date().getFullYear()); // "2025" + + // WICHTIG: Schlüssel-Reihenfolge wie im 'soll' → top_tab zuerst + const filtersPayload = { + 'top_tab_recent_posts:0': JSON.stringify({ + name: 'top_tab_recent_posts', + args: '' + }), + 'rp_creation_time:0': JSON.stringify({ + name: 'creation_time', + args: JSON.stringify({ + start_year: y, // als String + start_month: `${y}-1`, // ohne Padding + end_year: y, // als String + end_month: `${y}-12`, + start_day: `${y}-1-1`, + end_day: `${y}-12-31` + }) + }) + }; + + const serialized = JSON.stringify(filtersPayload); + + // Base64 OHNE URL-Encode zurückgeben + if (typeof window !== 'undefined' && typeof window.btoa === 'function') { + return window.btoa(serialized); + } else if (typeof btoa === 'function') { + return btoa(serialized); + } else if (typeof Buffer !== 'undefined') { + return Buffer.from(serialized, 'utf8').toString('base64'); + } else { + console.warn('Base64 nicht verfügbar, gebe JSON zurück (wird von URLSearchParams encodet).'); + return serialized; // kein encodeURIComponent! + } +} +*/ +function buildBookmarkSearchUrl(query) { + const trimmed = typeof query === 'string' ? query.trim() : ''; + if (!trimmed) { + return null; + } + + const searchUrl = new URL(BOOKMARKS_BASE_URL); + searchUrl.searchParams.set('q', trimmed); + searchUrl.searchParams.set('filters', buildBookmarkFiltersParam()); + return searchUrl.toString(); +} + +function buildBookmarkSearchQueries(baseQuery) { + const trimmed = typeof baseQuery === 'string' ? baseQuery.trim() : ''; + if (!trimmed) { + return [...BOOKMARK_SUFFIXES]; + } + + return BOOKMARK_SUFFIXES.map((suffix) => `${trimmed} ${suffix}`.trim()); +} + +function openBookmark(bookmark) { + if (!bookmark) { + return; + } + + const queries = buildBookmarkSearchQueries(bookmark.query); + if (!queries.length) { + queries.push(''); + } + + queries.forEach((searchTerm) => { + const url = buildBookmarkSearchUrl(searchTerm); + if (url) { + window.open(url, '_blank', 'noopener'); + } + }); +} + +function removeBookmark(bookmarkId) { + if (!bookmarkId) { + return; + } + + const current = loadCustomBookmarks(); + const next = current.filter((bookmark) => bookmark.id !== bookmarkId); + if (next.length === current.length) { + return; + } + saveCustomBookmarks(next); + renderBookmarks(); +} + +function renderBookmarks() { + if (!bookmarksList) { + return; + } + + bookmarksList.innerHTML = ''; + + const items = [...DEFAULT_BOOKMARKS, ...loadCustomBookmarks()]; + + const staticDefault = { + id: 'default-empty', + label: 'Gewinnspiel / gewinnen / verlosen', + query: '', + type: 'default' + }; + + items.unshift(staticDefault); + + if (!items.length) { + const empty = document.createElement('div'); + empty.className = 'bookmark-empty'; + empty.textContent = 'Noch keine Bookmarks vorhanden.'; + empty.setAttribute('role', 'listitem'); + bookmarksList.appendChild(empty); + return; + } + + items.forEach((bookmark) => { + const item = document.createElement('div'); + item.className = 'bookmark-item'; + item.setAttribute('role', 'listitem'); + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'bookmark-button'; + const label = bookmark.label || bookmark.query; + button.textContent = label; + + const searchVariants = buildBookmarkSearchQueries(bookmark.query); + if (searchVariants.length) { + button.title = searchVariants.map((variant) => `• ${variant}`).join('\n'); + } else { + button.title = `Suche nach "${bookmark.query}" (letzte 4 Wochen)`; + } + button.addEventListener('click', () => openBookmark(bookmark)); + + item.appendChild(button); + + if (bookmark.type === 'custom') { + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'bookmark-remove-btn'; + removeBtn.setAttribute('aria-label', `${bookmark.label || bookmark.query} entfernen`); + removeBtn.textContent = '×'; + removeBtn.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + removeBookmark(bookmark.id); + }); + item.appendChild(removeBtn); + } + + bookmarksList.appendChild(item); + }); +} + +function resetBookmarkForm() { + if (!bookmarkForm) { + return; + } + + bookmarkForm.reset(); + if (bookmarkNameInput) { + bookmarkNameInput.value = ''; + } + if (bookmarkQueryInput) { + bookmarkQueryInput.value = ''; + } +} + +function ensureBookmarkOutsideHandler() { + if (bookmarkOutsideHandler) { + return bookmarkOutsideHandler; + } + + bookmarkOutsideHandler = (event) => { + if (!bookmarkPanelVisible) { + return; + } + + const target = event.target; + const insidePanel = bookmarkPanel && bookmarkPanel.contains(target); + const onToggle = bookmarkPanelToggle && bookmarkPanelToggle.contains(target); + if (!insidePanel && !onToggle) { + toggleBookmarkPanel(false); + } + }; + + return bookmarkOutsideHandler; +} + +function removeBookmarkOutsideHandler() { + if (!bookmarkOutsideHandler) { + return; + } + document.removeEventListener('mousedown', bookmarkOutsideHandler, true); + document.removeEventListener('focusin', bookmarkOutsideHandler); +} + +function toggleBookmarkPanel(forceVisible) { + if (!bookmarkPanel || !bookmarkPanelToggle) { + return; + } + + const shouldShow = typeof forceVisible === 'boolean' ? forceVisible : !bookmarkPanelVisible; + if (shouldShow === bookmarkPanelVisible) { + return; + } + + bookmarkPanelVisible = shouldShow; + bookmarkPanel.hidden = !bookmarkPanelVisible; + bookmarkPanel.setAttribute('aria-hidden', bookmarkPanelVisible ? 'false' : 'true'); + bookmarkPanelToggle.setAttribute('aria-expanded', bookmarkPanelVisible ? 'true' : 'false'); + + if (bookmarkPanelVisible) { + renderBookmarks(); + resetBookmarkForm(); + if (bookmarkQueryInput) { + window.requestAnimationFrame(() => { + bookmarkQueryInput.focus(); + }); + } + const handler = ensureBookmarkOutsideHandler(); + window.requestAnimationFrame(() => { + document.addEventListener('mousedown', handler, true); + document.addEventListener('focusin', handler); + }); + } else { + resetBookmarkForm(); + removeBookmarkOutsideHandler(); + if (bookmarkPanelToggle) { + bookmarkPanelToggle.focus(); + } + } +} + +function handleBookmarkSubmit(event) { + event.preventDefault(); + + if (!bookmarkForm) { + return; + } + + const query = bookmarkQueryInput ? bookmarkQueryInput.value.trim() : ''; + const name = bookmarkNameInput ? bookmarkNameInput.value.trim() : ''; + + if (!query) { + resetBookmarkForm(); + if (bookmarkQueryInput) { + bookmarkQueryInput.focus(); + } + return; + } + + const customBookmarks = loadCustomBookmarks(); + const normalizedQuery = query.toLowerCase(); + const existingIndex = customBookmarks.findIndex((bookmark) => bookmark.query.toLowerCase() === normalizedQuery); + + const nextBookmark = { + id: existingIndex >= 0 ? customBookmarks[existingIndex].id : `custom-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + label: name || query, + query, + type: 'custom' + }; + + if (existingIndex >= 0) { + customBookmarks[existingIndex] = nextBookmark; + } else { + customBookmarks.push(nextBookmark); + } + + saveCustomBookmarks(customBookmarks); + renderBookmarks(); + resetBookmarkForm(); +} + +function initializeBookmarks() { + if (!bookmarksList) { + return; + } + + renderBookmarks(); + + if (bookmarkPanel) { + bookmarkPanel.setAttribute('aria-hidden', 'true'); + } + + if (bookmarkPanelToggle) { + bookmarkPanelToggle.addEventListener('click', () => { + toggleBookmarkPanel(); + }); + } + + if (bookmarkPanelClose) { + bookmarkPanelClose.addEventListener('click', () => { + toggleBookmarkPanel(false); + }); + } + + if (bookmarkCancelBtn) { + bookmarkCancelBtn.addEventListener('click', () => { + resetBookmarkForm(); + if (bookmarkQueryInput) { + bookmarkQueryInput.focus(); + } + }); + } + + if (bookmarkForm) { + bookmarkForm.addEventListener('submit', handleBookmarkSubmit); + } +} + function getSortSettingsPageKey() { try { const path = window.location.pathname; @@ -532,7 +970,9 @@ function normalizeFacebookPostUrl(rawValue) { const cleanedParams = new URLSearchParams(); parsed.searchParams.forEach((paramValue, paramKey) => { const lowerKey = paramKey.toLowerCase(); - if (FACEBOOK_TRACKING_PARAMS.some((trackingKey) => lowerKey.startsWith(trackingKey))) { + const isTrackingParam = FACEBOOK_TRACKING_PARAMS.some((trackingKey) => lowerKey.startsWith(trackingKey)); + const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit'; + if (isTrackingParam || isSingleUnitParam) { return; } cleanedParams.append(paramKey, paramValue); @@ -2842,6 +3282,12 @@ document.addEventListener('keydown', (event) => { return; } + if (bookmarkPanelVisible) { + event.preventDefault(); + toggleBookmarkPanel(false); + return; + } + if (screenshotModal && screenshotModal.classList.contains('open')) { if (screenshotModalZoomed) { resetScreenshotZoom(); @@ -2858,6 +3304,7 @@ window.addEventListener('resize', () => { }); // Initialize +initializeBookmarks(); loadAutoRefreshSettings(); initializeTabFromUrl(); loadSortMode(); diff --git a/web/index.html b/web/index.html index 039ab9c..dd2295f 100644 --- a/web/index.html +++ b/web/index.html @@ -14,9 +14,36 @@

📋 Post Tracker

-
+
diff --git a/web/style.css b/web/style.css index 0b2276f..223e01e 100644 --- a/web/style.css +++ b/web/style.css @@ -35,6 +35,13 @@ header { gap: 12px; } +.header-links { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + .header-controls { display: flex; flex-wrap: wrap; @@ -1061,6 +1068,166 @@ h1 { cursor: not-allowed; } +.bookmark-inline { + position: relative; + display: inline-flex; + align-items: center; + margin: 0; +} + +.bookmark-inline__toggle { + padding: 8px 14px; + font-size: 14px; +} + +.bookmark-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: min(420px, 90vw); + background: #ffffff; + border-radius: 10px; + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18); + padding: 16px; + z-index: 20; + border: 1px solid rgba(229, 231, 235, 0.8); +} + +.bookmark-panel__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.bookmark-panel__title { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.bookmark-panel__close { + border: none; + background: transparent; + font-size: 20px; + line-height: 1; + cursor: pointer; + color: #4b5563; + padding: 2px 6px; +} + +.bookmark-panel__close:hover, +.bookmark-panel__close:focus { + color: #ef4444; +} + +.bookmark-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.bookmark-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: #f0f2f5; + border-radius: 999px; +} + +.bookmark-button { + border: none; + background: transparent; + color: #1d2129; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + font-size: 14px; +} + +.bookmark-button:hover, +.bookmark-button:focus { + text-decoration: underline; +} + +.bookmark-button:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.bookmark-remove-btn { + border: none; + background: transparent; + color: #7f8186; + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 0 2px; +} + +.bookmark-remove-btn:hover, +.bookmark-remove-btn:focus { + color: #c0392b; +} + +.bookmark-form { + margin-top: 12px; + border-top: 1px solid #e4e6eb; + padding-top: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.bookmark-form__fields { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.bookmark-form__field { + display: flex; + flex-direction: column; + flex: 1 1 220px; + gap: 6px; +} + +.bookmark-form__field span { + font-size: 13px; + color: #65676b; +} + +.bookmark-form__field input { + border: 1px solid #d0d3d9; + border-radius: 6px; + padding: 8px 10px; + font-size: 14px; +} + +.bookmark-form__actions { + display: flex; + gap: 10px; +} + +.bookmark-form__hint { + margin: 0; + font-size: 12px; + color: #65676b; +} + +.bookmark-empty { + font-size: 14px; + color: #65676b; + background: #f0f2f5; + border-radius: 8px; + padding: 12px; +} + .screenshot-modal { position: fixed; inset: 0; @@ -1184,6 +1351,16 @@ h1 { padding: 14px; } + .header-main { + flex-direction: column; + align-items: flex-start; + } + + .header-links { + width: 100%; + justify-content: flex-start; + } + .post-card { padding-left: 20px; } @@ -1222,4 +1399,19 @@ h1 { .btn { width: 100%; } + + .bookmark-inline { + display: block; + margin-bottom: 10px; + } + + .bookmark-panel { + position: static; + width: 100%; + margin-top: 10px; + } + + .bookmark-form__actions { + flex-direction: column; + } }