Aktueller Stand
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- ./backend/server.js:/app/server.js:ro
|
||||
- db-data:/app/data
|
||||
- /opt/docker/posttracker/data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
@@ -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:
|
||||
@@ -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 = `
|
||||
<div style="color: #65676b; white-space: nowrap;">
|
||||
<strong>Tracker:</strong> ${statusText}${completed ? ' ✓' : ''}${expiredText}
|
||||
</div>
|
||||
${lastCheck ? `<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${lastCheck}</div>` : ''}
|
||||
`;
|
||||
|
||||
if (canCurrentProfileCheck && !isExpired && !completed) {
|
||||
statusHtml += `
|
||||
<button class="fb-tracker-check-btn" style="
|
||||
padding: 4px 12px;
|
||||
background-color: #42b72a;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
">
|
||||
✓ Bestätigen
|
||||
</button>
|
||||
`;
|
||||
} else if (isCurrentProfileDone) {
|
||||
statusHtml += `
|
||||
<div style="color: #42b72a; font-size: 12px; white-space: nowrap; margin-left: 8px;">
|
||||
✓ Von dir bestätigt
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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
|
||||
const renderResult = await renderTrackedStatus({
|
||||
container,
|
||||
postElement,
|
||||
postData,
|
||||
profileNumber,
|
||||
isFeedHome,
|
||||
isDialogContext,
|
||||
manualHideInfo,
|
||||
encodedUrl,
|
||||
postNum
|
||||
});
|
||||
|
||||
if (renderResult && renderResult.hidden) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let statusHtml = `
|
||||
<div style="color: #65676b; white-space: nowrap;">
|
||||
<strong>Tracker:</strong> ${statusText}${completed ? ' ✓' : ''}${expiredText}
|
||||
</div>
|
||||
${lastCheck ? `<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${lastCheck}</div>` : ''}
|
||||
`;
|
||||
|
||||
// Add check button if current profile can check and not expired
|
||||
if (canCurrentProfileCheck && !isExpired && !completed) {
|
||||
statusHtml += `
|
||||
<button class="fb-tracker-check-btn" style="
|
||||
padding: 4px 12px;
|
||||
background-color: #42b72a;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
">
|
||||
✓ Bestätigen
|
||||
</button>
|
||||
`;
|
||||
} else if (isCurrentProfileDone) {
|
||||
statusHtml += `
|
||||
<div style="color: #42b72a; font-size: 12px; white-space: nowrap; margin-left: 8px;">
|
||||
✓ Von dir bestätigt
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div style="color: #65676b; white-space: nowrap;">
|
||||
<strong>Tracker:</strong> ${newStatusText}${newCompleted ? ' ✓' : ''}
|
||||
</div>
|
||||
<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${newLastCheck}</div>
|
||||
<div style="color: #42b72a; font-size: 12px; white-space: nowrap; margin-left: 8px;">
|
||||
✓ Von dir bestätigt
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<div style="color: #65676b; white-space: nowrap;">
|
||||
<strong>Tracker:</strong> ${statusText}${completed ? ' ✓' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (lastCheck) {
|
||||
statusHtml += `
|
||||
<div style="color: #65676b; font-size: 12px; white-space: nowrap;">
|
||||
Letzte: ${lastCheck}
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
|
||||
451
web/app.js
451
web/app.js
@@ -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();
|
||||
|
||||
@@ -14,9 +14,36 @@
|
||||
<header>
|
||||
<div class="header-main">
|
||||
<h1>📋 Post Tracker</h1>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<div class="header-links">
|
||||
<a href="?view=dashboard" class="btn btn-secondary">Dashboard</a>
|
||||
<a href="settings.html" class="btn btn-secondary">⚙️ Einstellungen</a>
|
||||
<div class="bookmark-inline">
|
||||
<button type="button" class="btn btn-secondary bookmark-inline__toggle" id="bookmarkPanelToggle" aria-expanded="false" aria-controls="bookmarkPanel">🔖 Bookmarks</button>
|
||||
<div id="bookmarkPanel" class="bookmark-panel" role="dialog" aria-modal="false" hidden>
|
||||
<div class="bookmark-panel__header">
|
||||
<h2 class="bookmark-panel__title">🔖 Bookmarks</h2>
|
||||
<button type="button" class="bookmark-panel__close" id="bookmarkPanelClose" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<div id="bookmarksList" class="bookmark-list" role="list" aria-live="polite"></div>
|
||||
<form id="bookmarkForm" class="bookmark-form" autocomplete="off">
|
||||
<div class="bookmark-form__fields">
|
||||
<label class="bookmark-form__field">
|
||||
<span>Titel</span>
|
||||
<input type="text" id="bookmarkName" maxlength="40" placeholder="Optionaler Titel">
|
||||
</label>
|
||||
<label class="bookmark-form__field">
|
||||
<span>Keyword *</span>
|
||||
<input type="text" id="bookmarkQuery" required placeholder="z.B. gewinnspiel">
|
||||
</label>
|
||||
</div>
|
||||
<div class="bookmark-form__actions">
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
<button type="button" class="btn btn-secondary" id="bookmarkCancelBtn">Zurücksetzen</button>
|
||||
</div>
|
||||
<p class="bookmark-form__hint">Öffnet für das Keyword drei Suchen (… Gewinnspiel / … gewinnen / … verlosen) mit Filter auf die letzten 4 Wochen.</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="openManualPostModalBtn">Beitrag hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
192
web/style.css
192
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user