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_COOKIE = 'fb_tracker_scope';
|
||||||
const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
|
const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
|
||||||
const FACEBOOK_TRACKING_PARAM_PREFIXES = ['__cft__', '__tn__', '__eep__', 'mibextid'];
|
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 SEARCH_POST_RETENTION_DAYS = 90;
|
||||||
|
|
||||||
const screenshotDir = path.join(__dirname, 'data', 'screenshots');
|
const screenshotDir = path.join(__dirname, 'data', 'screenshots');
|
||||||
@@ -277,10 +277,14 @@ function normalizeFacebookPostUrl(rawValue) {
|
|||||||
const cleanedParams = new URLSearchParams();
|
const cleanedParams = new URLSearchParams();
|
||||||
parsed.searchParams.forEach((paramValue, paramKey) => {
|
parsed.searchParams.forEach((paramValue, paramKey) => {
|
||||||
const lowerKey = paramKey.toLowerCase();
|
const lowerKey = paramKey.toLowerCase();
|
||||||
if (FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix)) || lowerKey === 'set' || lowerKey === 'comment_id') {
|
const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit';
|
||||||
return;
|
if (
|
||||||
}
|
FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix))
|
||||||
if (lowerKey === 'hoisted_section_header_type') {
|
|| lowerKey === 'set'
|
||||||
|
|| lowerKey === 'comment_id'
|
||||||
|
|| lowerKey === 'hoisted_section_header_type'
|
||||||
|
|| isSingleUnitParam
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cleanedParams.append(paramKey, paramValue);
|
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(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS checks (
|
CREATE TABLE IF NOT EXISTS checks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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 = []) {
|
function collectNormalizedFacebookUrls(primaryUrl, candidates = []) {
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
|
|
||||||
const pushNormalized = (value) => {
|
const pushNormalized = (value, expandVariants = true) => {
|
||||||
const normalizedUrl = normalizeFacebookPostUrl(value);
|
const normalizedUrl = normalizeFacebookPostUrl(value);
|
||||||
if (normalizedUrl && !normalized.includes(normalizedUrl)) {
|
if (normalizedUrl && !normalized.includes(normalizedUrl)) {
|
||||||
normalized.push(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;
|
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) {
|
function removeSearchSeenEntries(urls) {
|
||||||
if (!Array.isArray(urls) || urls.length === 0) {
|
if (!Array.isArray(urls) || urls.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -1191,9 +1368,6 @@ const updateSearchSeenStmt = db.prepare(`
|
|||||||
SET seen_count = ?, manually_hidden = ?, last_seen_at = CURRENT_TIMESTAMP
|
SET seen_count = ?, manually_hidden = ?, last_seen_at = CURRENT_TIMESTAMP
|
||||||
WHERE url = ?
|
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();
|
const checkIndexes = db.prepare("PRAGMA index_list('checks')").all();
|
||||||
for (const idx of checkIndexes) {
|
for (const idx of checkIndexes) {
|
||||||
if (idx.unique) {
|
if (idx.unique) {
|
||||||
@@ -1274,6 +1448,9 @@ function mapPostRow(post) {
|
|||||||
checked_at: sqliteTimestampToUTC(status.checked_at)
|
checked_at: sqliteTimestampToUTC(status.checked_at)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const alternateUrlRows = selectAlternateUrlsForPostStmt.all(post.id);
|
||||||
|
const alternateUrls = alternateUrlRows.map(row => row.url);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...post,
|
...post,
|
||||||
created_at: sqliteTimestampToUTC(post.created_at),
|
created_at: sqliteTimestampToUTC(post.created_at),
|
||||||
@@ -1289,7 +1466,8 @@ function mapPostRow(post) {
|
|||||||
created_by_profile: creatorProfile,
|
created_by_profile: creatorProfile,
|
||||||
created_by_profile_name: creatorProfile ? getProfileName(creatorProfile) : null,
|
created_by_profile_name: creatorProfile ? getProfileName(creatorProfile) : null,
|
||||||
created_by_name: creatorName,
|
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' });
|
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) {
|
if (!post) {
|
||||||
return res.json(null);
|
return res.json(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const alternates = collectPostAlternateUrls(post.url, [normalizedUrl]);
|
||||||
|
if (alternates.length) {
|
||||||
|
storePostUrls(post.id, post.url, alternates);
|
||||||
|
}
|
||||||
|
|
||||||
res.json(mapPostRow(post));
|
res.json(mapPostRow(post));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -1344,16 +1526,19 @@ app.post('/api/search-posts', (req, res) => {
|
|||||||
|
|
||||||
cleanupExpiredSearchPosts();
|
cleanupExpiredSearchPosts();
|
||||||
|
|
||||||
let isTracked = false;
|
let trackedPost = null;
|
||||||
for (const candidate of normalizedUrls) {
|
for (const candidate of normalizedUrls) {
|
||||||
const tracked = selectTrackedPostStmt.get(candidate);
|
const found = findPostByUrl(candidate);
|
||||||
if (tracked) {
|
if (found) {
|
||||||
isTracked = true;
|
trackedPost = found;
|
||||||
deleteSearchSeenStmt.run(candidate);
|
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 });
|
return res.json({ seen_count: 0, should_hide: false, tracked: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1555,6 +1740,7 @@ app.post('/api/posts', (req, res) => {
|
|||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count);
|
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);
|
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);
|
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));
|
res.json(mapPostRow(post));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1607,6 +1795,12 @@ app.put('/api/posts/:postId', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { postId } = req.params;
|
const { postId } = req.params;
|
||||||
const { target_count, title, created_by_profile, created_by_name, deadline_at, url } = req.body || {};
|
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 updates = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -1672,9 +1866,8 @@ app.put('/api/posts/:postId', (req, res) => {
|
|||||||
params.push(postId);
|
params.push(postId);
|
||||||
|
|
||||||
const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`);
|
const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`);
|
||||||
let result;
|
|
||||||
try {
|
try {
|
||||||
result = stmt.run(...params);
|
stmt.run(...params);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||||
return res.status(409).json({ error: 'Post with this URL already exists' });
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
return res.status(404).json({ error: 'Post not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
recalcCheckedCount(postId);
|
recalcCheckedCount(postId);
|
||||||
|
|
||||||
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||||
|
|
||||||
if (normalizedUrlForCleanup) {
|
const alternateCandidates = [...alternateUrlsInput];
|
||||||
removeSearchSeenEntries([normalizedUrlForCleanup]);
|
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));
|
res.json(mapPostRow(updatedPost));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
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)
|
// Check by URL (for web interface auto-check)
|
||||||
app.post('/api/check-by-url', (req, res) => {
|
app.post('/api/check-by-url', (req, res) => {
|
||||||
try {
|
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' });
|
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) {
|
if (!post) {
|
||||||
return res.status(404).json({ error: 'Post not found' });
|
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
|
// Check if deadline has passed
|
||||||
if (post.deadline_at) {
|
if (post.deadline_at) {
|
||||||
const deadline = new Date(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;
|
const { url, is_successful } = req.body;
|
||||||
|
|
||||||
// Check if post exists
|
// Check if post exists
|
||||||
const post = db.prepare('SELECT id FROM posts WHERE id = ?').get(postId);
|
const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||||
if (!post) {
|
if (!existingPost) {
|
||||||
return res.status(404).json({ error: 'Post not found' });
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1989,7 +2219,15 @@ app.patch('/api/posts/:postId', (req, res) => {
|
|||||||
|
|
||||||
// Update URL
|
// Update URL
|
||||||
db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(normalizedUrl, postId);
|
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 });
|
return res.json({ success: true, url: normalizedUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: fb-tracker-backend
|
container_name: fb-tracker-backend
|
||||||
ports:
|
ports:
|
||||||
- "3001:3000"
|
- "3001:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/server.js:/app/server.js:ro
|
- ./backend/server.js:/app/server.js:ro
|
||||||
- db-data:/app/data
|
- /opt/docker/posttracker/data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
labels:
|
labels:
|
||||||
- com.centurylinklabs.watchtower.enable=false
|
- com.centurylinklabs.watchtower.enable=false
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: ./web
|
build: ./web
|
||||||
container_name: fb-tracker-web
|
container_name: fb-tracker-web
|
||||||
@@ -24,6 +24,28 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
|
|||||||
@@ -486,6 +486,49 @@ function getPostUrl(postElement, postNum = '?') {
|
|||||||
return { url: '', allCandidates: [] };
|
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)
|
// Check if post is already tracked (checks all URL candidates to avoid duplicates)
|
||||||
async function checkPostStatus(postUrl, allUrlCandidates = []) {
|
async function checkPostStatus(postUrl, allUrlCandidates = []) {
|
||||||
try {
|
try {
|
||||||
@@ -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 foundPost = null;
|
||||||
let foundUrl = null;
|
let foundUrl = null;
|
||||||
|
|
||||||
// Check each URL
|
// 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)}`);
|
const response = await backendFetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(url)}`);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -544,10 +604,14 @@ async function checkPostStatus(postUrl, allUrlCandidates = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (foundPost) {
|
if (foundPost) {
|
||||||
|
const urlsForPersistence = allUrlsToCheck.filter(urlValue => urlValue && urlValue !== foundPost.url);
|
||||||
|
if (urlsForPersistence.length) {
|
||||||
|
await persistAlternatePostUrls(foundPost.id, urlsForPersistence);
|
||||||
|
}
|
||||||
return foundPost;
|
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;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[FB Tracker] Error checking post status:', 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
|
// Add post to tracking
|
||||||
async function markPostChecked(postId, profileNumber) {
|
async function markPostChecked(postId, profileNumber) {
|
||||||
try {
|
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);
|
const normalizedUrl = normalizeFacebookPostUrl(postUrl);
|
||||||
if (!normalizedUrl) {
|
if (!normalizedUrl) {
|
||||||
console.error('[FB Tracker] Post URL konnte nicht normalisiert werden:', postUrl);
|
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
|
created_by_profile: profileNumber
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (alternateCandidates.length) {
|
||||||
|
payload.alternate_urls = alternateCandidates;
|
||||||
|
}
|
||||||
|
|
||||||
if (createdByName) {
|
if (createdByName) {
|
||||||
payload.created_by_name = 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);
|
console.log('[FB Tracker] Post added successfully:', data);
|
||||||
|
|
||||||
if (data && data.id) {
|
if (data && data.id) {
|
||||||
const checkedData = await markPostChecked(data.id, profileNumber);
|
|
||||||
await captureAndUploadScreenshot(data.id, options.postElement || null);
|
await captureAndUploadScreenshot(data.id, options.postElement || null);
|
||||||
if (checkedData) {
|
|
||||||
return checkedData;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -1690,6 +1779,7 @@ function normalizeFacebookPostUrl(rawValue) {
|
|||||||
const cleanedParams = new URLSearchParams();
|
const cleanedParams = new URLSearchParams();
|
||||||
parsed.searchParams.forEach((paramValue, paramKey) => {
|
parsed.searchParams.forEach((paramValue, paramKey) => {
|
||||||
const lowerKey = paramKey.toLowerCase();
|
const lowerKey = paramKey.toLowerCase();
|
||||||
|
const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit';
|
||||||
if (
|
if (
|
||||||
lowerKey.startsWith('__cft__')
|
lowerKey.startsWith('__cft__')
|
||||||
|| lowerKey.startsWith('__tn__')
|
|| lowerKey.startsWith('__tn__')
|
||||||
@@ -1698,6 +1788,7 @@ function normalizeFacebookPostUrl(rawValue) {
|
|||||||
|| lowerKey === 'set'
|
|| lowerKey === 'set'
|
||||||
|| lowerKey === 'comment_id'
|
|| lowerKey === 'comment_id'
|
||||||
|| lowerKey === 'hoisted_section_header_type'
|
|| lowerKey === 'hoisted_section_header_type'
|
||||||
|
|| isSingleUnitParam
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1721,6 +1812,127 @@ function normalizeFacebookPostUrl(rawValue) {
|
|||||||
return formatted.replace(/[?&]$/, '');
|
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
|
// Create the tracking UI
|
||||||
async function createTrackerUI(postElement, buttonBar, postNum = '?', options = {}) {
|
async function createTrackerUI(postElement, buttonBar, postNum = '?', options = {}) {
|
||||||
// Normalize to top-level post container if nested element passed
|
// 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.id = 'fb-tracker-ui-post-' + postNum;
|
||||||
container.setAttribute('data-post-num', postNum);
|
container.setAttribute('data-post-num', postNum);
|
||||||
container.setAttribute('data-post-url', encodedUrl);
|
container.setAttribute('data-post-url', encodedUrl);
|
||||||
|
container.dataset.isFeedHome = isFeedHome ? '1' : '0';
|
||||||
|
container.dataset.isDialogContext = isDialogContext ? '1' : '0';
|
||||||
container.style.cssText = `
|
container.style.cssText = `
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background-color: #f0f2f5;
|
background-color: #f0f2f5;
|
||||||
@@ -1894,111 +2108,21 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (postData) {
|
if (postData) {
|
||||||
const checkedCount = postData.checked_count ?? (Array.isArray(postData.checks) ? postData.checks.length : 0);
|
const renderResult = await renderTrackedStatus({
|
||||||
const statusText = `${checkedCount}/${postData.target_count}`;
|
container,
|
||||||
const completed = checkedCount >= postData.target_count;
|
postElement,
|
||||||
const lastCheck = Array.isArray(postData.checks) && postData.checks.length
|
postData,
|
||||||
? new Date(postData.checks[postData.checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' })
|
profileNumber,
|
||||||
: null;
|
isFeedHome,
|
||||||
|
isDialogContext,
|
||||||
|
manualHideInfo,
|
||||||
|
encodedUrl,
|
||||||
|
postNum
|
||||||
|
});
|
||||||
|
|
||||||
// Check if deadline has passed
|
if (renderResult && renderResult.hidden) {
|
||||||
const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false;
|
return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
// Post not tracked - show add UI
|
// Post not tracked - show add UI
|
||||||
const selectId = `tracker-select-${Date.now()}`;
|
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, {
|
const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, {
|
||||||
postElement,
|
postElement,
|
||||||
deadline: deadlineValue
|
deadline: deadlineValue,
|
||||||
|
candidates: postUrlData.allCandidates
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
const checks = Array.isArray(result.checks) ? result.checks : [];
|
const renderOutcome = await renderTrackedStatus({
|
||||||
const checkedCount = typeof result.checked_count === 'number' ? result.checked_count : checks.length;
|
container,
|
||||||
const targetTotal = result.target_count || targetCount;
|
postElement,
|
||||||
const statusText = `${checkedCount}/${targetTotal}`;
|
postData: result,
|
||||||
const completed = checkedCount >= targetTotal;
|
profileNumber,
|
||||||
const lastCheck = checks.length ? new Date(checks[checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : null;
|
isFeedHome,
|
||||||
|
isDialogContext,
|
||||||
|
manualHideInfo,
|
||||||
|
encodedUrl,
|
||||||
|
postNum
|
||||||
|
});
|
||||||
|
|
||||||
let statusHtml = `
|
if (renderOutcome && renderOutcome.hidden) {
|
||||||
<div style="color: #65676b; white-space: nowrap;">
|
return;
|
||||||
<strong>Tracker:</strong> ${statusText}${completed ? ' ✓' : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (lastCheck) {
|
|
||||||
statusHtml += `
|
|
||||||
<div style="color: #65676b; font-size: 12px; white-space: nowrap;">
|
|
||||||
Letzte: ${lastCheck}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = statusHtml;
|
return;
|
||||||
if (deadlineInput) {
|
|
||||||
deadlineInput.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
await addAICommentButton(container, postElement);
|
|
||||||
} else {
|
} else {
|
||||||
// Error
|
// Error
|
||||||
addButton.disabled = false;
|
addButton.disabled = false;
|
||||||
@@ -3667,6 +3782,77 @@ async function addAICommentButton(container, postElement) {
|
|||||||
window.removeEventListener('resize', repositionDropdown);
|
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) => {
|
const handleOutsideClick = (event) => {
|
||||||
if (!wrapper.contains(event.target) && !dropdown.contains(event.target)) {
|
if (!wrapper.contains(event.target) && !dropdown.contains(event.target)) {
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
@@ -4134,6 +4320,7 @@ async function addAICommentButton(container, postElement) {
|
|||||||
throwIfCancelled();
|
throwIfCancelled();
|
||||||
showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info');
|
showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info');
|
||||||
restoreIdle('📋 Kopiert', 2000);
|
restoreIdle('📋 Kopiert', 2000);
|
||||||
|
await confirmParticipationAfterAI(profileNumber);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4148,11 +4335,13 @@ async function addAICommentButton(container, postElement) {
|
|||||||
if (success) {
|
if (success) {
|
||||||
showToast('✓ Kommentar wurde eingefügt', 'success');
|
showToast('✓ Kommentar wurde eingefügt', 'success');
|
||||||
restoreIdle('✓ Eingefügt', 2000);
|
restoreIdle('✓ Eingefügt', 2000);
|
||||||
|
await confirmParticipationAfterAI(profileNumber);
|
||||||
} else {
|
} else {
|
||||||
await navigator.clipboard.writeText(comment);
|
await navigator.clipboard.writeText(comment);
|
||||||
throwIfCancelled();
|
throwIfCancelled();
|
||||||
showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info');
|
showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info');
|
||||||
restoreIdle('📋 Kopiert', 2000);
|
restoreIdle('📋 Kopiert', 2000);
|
||||||
|
await confirmParticipationAfterAI(profileNumber);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const cancelled = aiContext.cancelled || isCancellationError(error);
|
const cancelled = aiContext.cancelled || isCancellationError(error);
|
||||||
|
|||||||
453
web/app.js
453
web/app.js
@@ -25,7 +25,7 @@ const PROFILE_NAMES = {
|
|||||||
4: 'Profil 4',
|
4: 'Profil 4',
|
||||||
5: 'Profil 5'
|
5: 'Profil 5'
|
||||||
};
|
};
|
||||||
|
|
||||||
function apiFetch(url, options = {}) {
|
function apiFetch(url, options = {}) {
|
||||||
const config = {
|
const config = {
|
||||||
...options,
|
...options,
|
||||||
@@ -68,13 +68,26 @@ const autoRefreshToggle = document.getElementById('autoRefreshToggle');
|
|||||||
const autoRefreshIntervalSelect = document.getElementById('autoRefreshInterval');
|
const autoRefreshIntervalSelect = document.getElementById('autoRefreshInterval');
|
||||||
const sortModeSelect = document.getElementById('sortMode');
|
const sortModeSelect = document.getElementById('sortMode');
|
||||||
const sortDirectionToggle = document.getElementById('sortDirectionToggle');
|
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 REFRESH_SETTINGS_KEY = 'trackerRefreshSettings';
|
||||||
const SORT_SETTINGS_KEY = 'trackerSortSettings';
|
const SORT_SETTINGS_KEY = 'trackerSortSettings';
|
||||||
const SORT_SETTINGS_LEGACY_KEY = 'trackerSortMode';
|
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 VALID_SORT_MODES = new Set(['created', 'deadline', 'smart', 'lastCheck', 'lastChange']);
|
||||||
const DEFAULT_SORT_SETTINGS = { mode: 'created', direction: 'desc' };
|
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 autoRefreshTimer = null;
|
||||||
let autoRefreshSettings = {
|
let autoRefreshSettings = {
|
||||||
@@ -89,6 +102,8 @@ let manualPostEditingId = null;
|
|||||||
let manualPostModalLastFocus = null;
|
let manualPostModalLastFocus = null;
|
||||||
let manualPostModalPreviousOverflow = '';
|
let manualPostModalPreviousOverflow = '';
|
||||||
let activeDeadlinePicker = null;
|
let activeDeadlinePicker = null;
|
||||||
|
let bookmarkPanelVisible = false;
|
||||||
|
let bookmarkOutsideHandler = null;
|
||||||
|
|
||||||
const INITIAL_POST_LIMIT = 10;
|
const INITIAL_POST_LIMIT = 10;
|
||||||
const POST_LOAD_INCREMENT = 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() {
|
function getSortSettingsPageKey() {
|
||||||
try {
|
try {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
@@ -532,7 +970,9 @@ function normalizeFacebookPostUrl(rawValue) {
|
|||||||
const cleanedParams = new URLSearchParams();
|
const cleanedParams = new URLSearchParams();
|
||||||
parsed.searchParams.forEach((paramValue, paramKey) => {
|
parsed.searchParams.forEach((paramValue, paramKey) => {
|
||||||
const lowerKey = paramKey.toLowerCase();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
cleanedParams.append(paramKey, paramValue);
|
cleanedParams.append(paramKey, paramValue);
|
||||||
@@ -2842,6 +3282,12 @@ document.addEventListener('keydown', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bookmarkPanelVisible) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleBookmarkPanel(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (screenshotModal && screenshotModal.classList.contains('open')) {
|
if (screenshotModal && screenshotModal.classList.contains('open')) {
|
||||||
if (screenshotModalZoomed) {
|
if (screenshotModalZoomed) {
|
||||||
resetScreenshotZoom();
|
resetScreenshotZoom();
|
||||||
@@ -2858,6 +3304,7 @@ window.addEventListener('resize', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
|
initializeBookmarks();
|
||||||
loadAutoRefreshSettings();
|
loadAutoRefreshSettings();
|
||||||
initializeTabFromUrl();
|
initializeTabFromUrl();
|
||||||
loadSortMode();
|
loadSortMode();
|
||||||
|
|||||||
@@ -14,9 +14,36 @@
|
|||||||
<header>
|
<header>
|
||||||
<div class="header-main">
|
<div class="header-main">
|
||||||
<h1>📋 Post Tracker</h1>
|
<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="?view=dashboard" class="btn btn-secondary">Dashboard</a>
|
||||||
<a href="settings.html" class="btn btn-secondary">⚙️ Einstellungen</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>
|
<button type="button" class="btn btn-primary" id="openManualPostModalBtn">Beitrag hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
192
web/style.css
192
web/style.css
@@ -35,6 +35,13 @@ header {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1061,6 +1068,166 @@ h1 {
|
|||||||
cursor: not-allowed;
|
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 {
|
.screenshot-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1184,6 +1351,16 @@ h1 {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-links {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.post-card {
|
.post-card {
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
@@ -1222,4 +1399,19 @@ h1 {
|
|||||||
.btn {
|
.btn {
|
||||||
width: 100%;
|
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