Files
PostTracker/web/app.js
2025-11-16 13:58:32 +01:00

4181 lines
120 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const API_URL = 'https://fb.srv.medeba-media.de/api';
let initialViewParam = null;
try {
const params = new URLSearchParams(window.location.search);
initialViewParam = params.get('view');
} catch (error) {
console.warn('Konnte view-Parameter nicht auslesen:', error);
}
let focusPostIdParam = null;
let focusPostUrlParam = null;
let focusNormalizedUrl = '';
let focusHandled = false;
let initialTabOverride = null;
let focusTabAdjusted = null;
let currentProfile = 1;
let currentTab = 'pending';
let posts = [];
let profilePollTimer = null;
const UPDATES_RECONNECT_DELAY = 5000;
let updatesEventSource = null;
let updatesReconnectTimer = null;
let updatesStreamHealthy = false;
let updatesShouldResyncOnConnect = false;
const MAX_PROFILES = 5;
const PROFILE_NAMES = {
1: 'Profil 1',
2: 'Profil 2',
3: 'Profil 3',
4: 'Profil 4',
5: 'Profil 5'
};
function apiFetch(url, options = {}) {
const config = {
...options,
credentials: 'include'
};
if (options && options.headers) {
config.headers = { ...options.headers };
}
return fetch(url, config);
}
function sortPostsByCreatedAt() {
posts.sort((a, b) => toTimestamp(b.created_at, 0) - toTimestamp(a.created_at, 0));
}
function applyPostUpdateFromStream(post) {
if (!post || !post.id) {
return;
}
const index = posts.findIndex((item) => item.id === post.id);
if (index !== -1) {
posts[index] = post;
} else {
posts.push(post);
}
sortPostsByCreatedAt();
if (manualPostMode === 'edit' && manualPostEditingId === post.id) {
populateManualPostForm(post);
}
renderPosts();
}
function removePostFromCache(postId) {
if (!postId) {
return;
}
const index = posts.findIndex((item) => item.id === postId);
if (index === -1) {
return;
}
posts.splice(index, 1);
if (
manualPostMode === 'edit'
&& manualPostEditingId === postId
&& manualPostModal
&& manualPostModal.classList.contains('open')
) {
closeManualPostModal();
}
renderPosts();
}
function handleBackendEvent(eventPayload) {
if (!eventPayload || typeof eventPayload !== 'object') {
return;
}
switch (eventPayload.type) {
case 'post-upsert':
if (eventPayload.post) {
applyPostUpdateFromStream(eventPayload.post);
}
break;
case 'post-deleted':
if (eventPayload.postId) {
removePostFromCache(eventPayload.postId);
}
break;
case 'connected':
case 'heartbeat':
default:
break;
}
}
function scheduleUpdatesReconnect() {
if (updatesReconnectTimer) {
return;
}
updatesReconnectTimer = setTimeout(() => {
updatesReconnectTimer = null;
startUpdatesStream();
}, UPDATES_RECONNECT_DELAY);
}
function startUpdatesStream() {
if (typeof EventSource === 'undefined') {
console.warn('EventSource wird von diesem Browser nicht unterstützt. Fallback auf Polling.');
return;
}
if (updatesEventSource) {
return;
}
const eventsUrl = `${API_URL}/events`;
let eventSource;
try {
eventSource = new EventSource(eventsUrl, { withCredentials: true });
} catch (error) {
console.warn('Konnte Update-Stream nicht starten:', error);
scheduleUpdatesReconnect();
return;
}
updatesEventSource = eventSource;
eventSource.addEventListener('open', () => {
updatesStreamHealthy = true;
if (updatesReconnectTimer) {
clearTimeout(updatesReconnectTimer);
updatesReconnectTimer = null;
}
if (updatesShouldResyncOnConnect) {
updatesShouldResyncOnConnect = false;
fetchPosts({ showLoader: false });
}
applyAutoRefreshSettings();
});
eventSource.addEventListener('message', (event) => {
if (!event || typeof event.data !== 'string' || !event.data.trim()) {
return;
}
let payload;
try {
payload = JSON.parse(event.data);
} catch (error) {
console.warn('Ungültige Daten vom Update-Stream erhalten:', error);
return;
}
handleBackendEvent(payload);
});
eventSource.addEventListener('error', () => {
if (updatesEventSource) {
updatesEventSource.close();
updatesEventSource = null;
}
if (!updatesShouldResyncOnConnect) {
updatesShouldResyncOnConnect = true;
}
updatesStreamHealthy = false;
applyAutoRefreshSettings();
fetchPosts({ showLoader: false });
scheduleUpdatesReconnect();
});
}
const screenshotModal = document.getElementById('screenshotModal');
const screenshotModalContent = document.getElementById('screenshotModalContent');
const screenshotModalImage = document.getElementById('screenshotModalImage');
const screenshotModalClose = document.getElementById('screenshotModalClose');
const screenshotModalBackdrop = document.getElementById('screenshotModalBackdrop');
let screenshotModalLastFocus = null;
let screenshotModalPreviousOverflow = '';
let screenshotModalZoomed = false;
const manualPostForm = document.getElementById('manualPostForm');
const manualPostUrlInput = document.getElementById('manualPostUrl');
const manualPostTitleInput = document.getElementById('manualPostTitle');
const manualPostTargetSelect = document.getElementById('manualPostTarget');
const manualPostCreatorInput = document.getElementById('manualPostCreatorName');
const manualPostDeadlineInput = document.getElementById('manualPostDeadline');
const manualPostResetButton = document.getElementById('manualPostReset');
const manualPostMessage = document.getElementById('manualPostMessage');
const manualPostSubmitButton = document.getElementById('manualPostSubmitBtn');
const manualPostModal = document.getElementById('manualPostModal');
const manualPostModalContent = document.getElementById('manualPostModalContent');
const manualPostModalBackdrop = document.getElementById('manualPostModalBackdrop');
const manualPostModalClose = document.getElementById('manualPostModalClose');
const manualPostModalTitle = document.getElementById('manualPostModalTitle');
const openManualPostModalBtn = document.getElementById('openManualPostModalBtn');
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 bookmarkSearchInput = document.getElementById('bookmarkSearchInput');
const bookmarkSortSelect = document.getElementById('bookmarkSortSelect');
const bookmarkSortDirectionToggle = document.getElementById('bookmarkSortDirectionToggle');
const profileSelectElement = document.getElementById('profileSelect');
const REFRESH_SETTINGS_KEY = 'trackerRefreshSettings';
const SORT_SETTINGS_KEY = 'trackerSortSettings';
const SORT_SETTINGS_LEGACY_KEY = 'trackerSortMode';
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_BASE_URL = 'https://www.facebook.com/search/top';
const BOOKMARK_WINDOW_DAYS = 28;
const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen'];
const BOOKMARK_PREFS_KEY = 'trackerBookmarkPreferences';
function initializeFocusParams() {
try {
const params = new URLSearchParams(window.location.search);
const postIdParam = params.get('postId');
const postUrlParam = params.get('postUrl');
if (postIdParam && postIdParam.trim()) {
focusPostIdParam = postIdParam.trim();
initialTabOverride = initialTabOverride || 'all';
}
if (postUrlParam && postUrlParam.trim()) {
focusPostUrlParam = postUrlParam.trim();
const normalized = normalizeFacebookPostUrl(focusPostUrlParam);
focusNormalizedUrl = normalized || focusPostUrlParam;
initialTabOverride = initialTabOverride || 'all';
}
focusHandled = false;
focusTabAdjusted = null;
} catch (error) {
console.warn('Konnte Fokus-Parameter nicht verarbeiten:', error);
}
}
let autoRefreshTimer = null;
let autoRefreshSettings = {
enabled: false,
interval: 30000
};
let sortMode = DEFAULT_SORT_SETTINGS.mode;
let sortDirection = DEFAULT_SORT_SETTINGS.direction;
let isFetchingPosts = false;
let manualPostMode = 'create';
let manualPostEditingId = null;
let manualPostModalLastFocus = null;
let manualPostModalPreviousOverflow = '';
let activeDeadlinePicker = null;
let bookmarkPanelVisible = false;
let bookmarkOutsideHandler = null;
let bookmarkSearchTerm = '';
let bookmarkSortMode = 'recent';
let bookmarkSortDirection = 'desc';
function loadBookmarkPreferences() {
try {
const stored = localStorage.getItem(BOOKMARK_PREFS_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (parsed && typeof parsed === 'object') {
return parsed;
}
}
} catch (error) {
console.warn('Konnte Bookmark-Einstellungen nicht laden:', error);
}
return {};
}
function persistBookmarkPreferences() {
try {
const payload = {
searchTerm: bookmarkSearchTerm,
sortMode: bookmarkSortMode,
sortDirection: bookmarkSortDirection
};
localStorage.setItem(BOOKMARK_PREFS_KEY, JSON.stringify(payload));
} catch (error) {
console.warn('Konnte Bookmark-Einstellungen nicht speichern:', error);
}
}
const storedBookmarkPrefs = loadBookmarkPreferences();
if (storedBookmarkPrefs.searchTerm && typeof storedBookmarkPrefs.searchTerm === 'string') {
bookmarkSearchTerm = storedBookmarkPrefs.searchTerm.trim();
}
if (storedBookmarkPrefs.sortMode === 'label' || storedBookmarkPrefs.sortMode === 'recent') {
bookmarkSortMode = storedBookmarkPrefs.sortMode;
}
if (storedBookmarkPrefs.sortDirection === 'asc' || storedBookmarkPrefs.sortDirection === 'desc') {
bookmarkSortDirection = storedBookmarkPrefs.sortDirection;
}
const INITIAL_POST_LIMIT = 10;
const POST_LOAD_INCREMENT = 10;
const tabVisibleCounts = {
pending: INITIAL_POST_LIMIT,
expired: INITIAL_POST_LIMIT,
all: INITIAL_POST_LIMIT
};
const tabFilteredCounts = {
pending: 0,
expired: 0,
all: 0
};
let loadMoreObserver = null;
let observedLoadMoreElement = null;
function getProfileName(profileNumber) {
if (!profileNumber) {
return '';
}
return PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`;
}
function formatDateTime(value) {
if (!value) {
return '';
}
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleString('de-DE', { timeZone: 'Europe/Berlin' });
} catch (error) {
console.warn('Ungültiges Datum:', error);
return '';
}
}
function formatDeadline(value) {
if (!value) {
return 'Keine Deadline';
}
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Ungültige Deadline';
}
return date.toLocaleString('de-DE', { timeZone: 'Europe/Berlin' });
} catch (error) {
console.warn('Ungültige Deadline:', error);
return 'Ungültige Deadline';
}
}
function formatUrlForDisplay(url) {
if (!url) {
return '';
}
try {
const parsed = new URL(url);
const pathname = parsed.pathname === '/' ? '' : parsed.pathname;
const search = parsed.search || '';
return `${parsed.hostname}${pathname}${search}`;
} catch (error) {
return url;
}
}
function toTimestamp(value, fallback = null) {
if (!value) {
return fallback;
}
const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime();
if (Number.isNaN(timestamp)) {
return fallback;
}
return timestamp;
}
function getSortTabKey(tab = currentTab) {
if (tab === 'expired') {
return 'expired';
}
if (tab === 'all') {
return 'all';
}
return 'pending';
}
function normalizeSortMode(value) {
if (VALID_SORT_MODES.has(value)) {
return value;
}
return DEFAULT_SORT_SETTINGS.mode;
}
function normalizeSortDirection(value) {
return value === 'asc' ? 'asc' : DEFAULT_SORT_SETTINGS.direction;
}
function getSortStorage() {
try {
const raw = localStorage.getItem(SORT_SETTINGS_KEY) || localStorage.getItem(SORT_SETTINGS_LEGACY_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
} catch (error) {
console.warn('Konnte Sortierdaten nicht parsen:', error);
}
return {};
}
function persistSortStorage(storage) {
try {
localStorage.setItem(SORT_SETTINGS_KEY, JSON.stringify(storage));
if (SORT_SETTINGS_KEY !== SORT_SETTINGS_LEGACY_KEY) {
localStorage.removeItem(SORT_SETTINGS_LEGACY_KEY);
}
} catch (error) {
console.warn('Konnte Sortierdaten nicht speichern:', error);
}
}
function normalizeServerBookmark(entry) {
if (!entry || typeof entry !== 'object') {
return null;
}
const id = typeof entry.id === 'string' ? entry.id : null;
const query = typeof entry.query === 'string' ? entry.query.trim() : '';
if (!id || !query) {
return null;
}
const label = typeof entry.label === 'string' && entry.label.trim() ? entry.label.trim() : query;
const normalizeDate = (value) => {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString();
};
return {
id,
label,
query,
created_at: normalizeDate(entry.created_at),
updated_at: normalizeDate(entry.updated_at),
last_clicked_at: normalizeDate(entry.last_clicked_at),
deletable: true
};
}
function deduplicateBookmarks(list) {
const seen = new Set();
const deduped = [];
list.forEach((bookmark) => {
if (!bookmark || !bookmark.query) {
return;
}
const key = bookmark.query.toLowerCase();
if (seen.has(key)) {
return;
}
seen.add(key);
deduped.push(bookmark);
});
return deduped;
}
function sortBookmarksByRecency(list) {
return [...list].sort((a, b) => {
const aClick = a.last_clicked_at ? new Date(a.last_clicked_at).getTime() : -Infinity;
const bClick = b.last_clicked_at ? new Date(b.last_clicked_at).getTime() : -Infinity;
if (aClick !== bClick) {
return bClick - aClick;
}
const aCreated = a.created_at ? new Date(a.created_at).getTime() : -Infinity;
const bCreated = b.created_at ? new Date(b.created_at).getTime() : -Infinity;
if (aCreated !== bCreated) {
return bCreated - aCreated;
}
return a.label.localeCompare(b.label, 'de', { sensitivity: 'base' });
});
}
function getBookmarkLabelForComparison(bookmark = {}) {
const label = typeof bookmark.label === 'string' ? bookmark.label.trim() : '';
const query = typeof bookmark.query === 'string' ? bookmark.query.trim() : '';
return label || query || '';
}
function getBookmarkClickTimestamp(bookmark = {}) {
if (bookmark.last_clicked_at) {
const ts = new Date(bookmark.last_clicked_at).getTime();
if (!Number.isNaN(ts)) {
return ts;
}
}
return 0;
}
function sortBookmarksForDisplay(list) {
const items = [...list];
if (bookmarkSortMode === 'label') {
items.sort((a, b) => {
const labelA = getBookmarkLabelForComparison(a);
const labelB = getBookmarkLabelForComparison(b);
const result = labelA.localeCompare(labelB, 'de', { sensitivity: 'base' });
return bookmarkSortDirection === 'desc' ? -result : result;
});
return items;
}
items.sort((a, b) => {
const diff = getBookmarkClickTimestamp(b) - getBookmarkClickTimestamp(a);
if (diff !== 0) {
return bookmarkSortDirection === 'desc' ? diff : -diff;
}
const fallback = getBookmarkLabelForComparison(a).localeCompare(
getBookmarkLabelForComparison(b),
'de',
{ sensitivity: 'base' }
);
return bookmarkSortDirection === 'desc' ? fallback : -fallback;
});
return items;
}
function filterBookmarksBySearch(list) {
if (!bookmarkSearchTerm) {
return [...list];
}
const term = bookmarkSearchTerm.toLowerCase();
return list.filter((bookmark) => {
const label = (bookmark.label || bookmark.query || '').toLowerCase();
const query = (bookmark.query || '').toLowerCase();
return label.includes(term) || query.includes(term);
});
}
function updateBookmarkSortDirectionUI() {
if (!bookmarkSortDirectionToggle) {
return;
}
const isAsc = bookmarkSortDirection === 'asc';
bookmarkSortDirectionToggle.setAttribute('aria-pressed', isAsc ? 'true' : 'false');
bookmarkSortDirectionToggle.setAttribute('title', isAsc ? 'Älteste zuerst' : 'Neueste zuerst');
const icon = bookmarkSortDirectionToggle.querySelector('.bookmark-sort__direction-icon');
if (icon) {
icon.textContent = isAsc ? '▲' : '▼';
} else {
bookmarkSortDirectionToggle.textContent = isAsc ? '▲' : '▼';
}
}
const DEFAULT_BOOKMARK_LAST_CLICK_KEY = 'trackerDefaultBookmarkLastClickedAt';
const bookmarkState = {
items: [],
loaded: false,
loading: false,
error: null,
defaultLastClickedAt: null
};
try {
const storedDefaultBookmark = localStorage.getItem(DEFAULT_BOOKMARK_LAST_CLICK_KEY);
if (storedDefaultBookmark) {
bookmarkState.defaultLastClickedAt = storedDefaultBookmark;
}
} catch (error) {
console.warn('Konnte letzte Nutzung des Standard-Bookmarks nicht laden:', error);
}
let bookmarkFetchPromise = null;
function formatRelativeTimeFromNow(timestamp) {
if (!timestamp) {
return 'Noch nie geöffnet';
}
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return 'Zuletzt: unbekannt';
}
const diffMs = Date.now() - date.getTime();
if (diffMs < 0) {
return 'gerade eben';
}
const diffSeconds = Math.floor(diffMs / 1000);
if (diffSeconds < 45) {
return 'vor wenigen Sekunden';
}
const diffMinutes = Math.floor(diffSeconds / 60);
if (diffMinutes < 60) {
return `vor ${diffMinutes} ${diffMinutes === 1 ? 'Minute' : 'Minuten'}`;
}
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) {
return `vor ${diffHours} ${diffHours === 1 ? 'Stunde' : 'Stunden'}`;
}
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 31) {
return `vor ${diffDays} ${diffDays === 1 ? 'Tag' : 'Tagen'}`;
}
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12) {
return `vor ${diffMonths} ${diffMonths === 1 ? 'Monat' : 'Monaten'}`;
}
const diffYears = Math.floor(diffMonths / 12);
return `vor ${diffYears} ${diffYears === 1 ? 'Jahr' : 'Jahren'}`;
}
function upsertBookmarkInState(bookmark) {
const normalized = normalizeServerBookmark(bookmark);
if (!normalized) {
return;
}
const lowerQuery = normalized.query.toLowerCase();
const existingIndex = bookmarkState.items.findIndex((item) => {
if (!item || !item.query) {
return false;
}
return item.id === normalized.id || item.query.toLowerCase() === lowerQuery;
});
if (existingIndex >= 0) {
bookmarkState.items[existingIndex] = { ...bookmarkState.items[existingIndex], ...normalized };
} else {
bookmarkState.items.push(normalized);
}
bookmarkState.items = deduplicateBookmarks(sortBookmarksByRecency(bookmarkState.items));
}
function removeBookmarkFromState(bookmarkId) {
if (!bookmarkId) {
return;
}
bookmarkState.items = bookmarkState.items.filter((bookmark) => bookmark.id !== bookmarkId);
}
async function refreshBookmarks(options = {}) {
const { force = false } = options;
if (bookmarkFetchPromise && !force) {
return bookmarkFetchPromise;
}
bookmarkFetchPromise = (async () => {
bookmarkState.loading = true;
if (!bookmarkState.loaded || force) {
renderBookmarks();
}
try {
const response = await apiFetch(`${API_URL}/bookmarks`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const normalized = Array.isArray(data)
? data.map(normalizeServerBookmark).filter(Boolean)
: [];
const finalList = deduplicateBookmarks(sortBookmarksByRecency(normalized));
bookmarkState.items = finalList;
bookmarkState.loaded = true;
bookmarkState.loading = false;
bookmarkState.error = null;
renderBookmarks();
return bookmarkState.items;
} catch (error) {
console.warn('Konnte Bookmarks nicht laden:', error);
bookmarkState.error = 'Bookmarks konnten nicht geladen werden.';
bookmarkState.loading = false;
if (!bookmarkState.loaded) {
bookmarkState.items = [];
}
renderBookmarks();
throw error;
} finally {
bookmarkFetchPromise = null;
}
})();
return bookmarkFetchPromise;
}
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('');
}
const stateBookmark = bookmarkState.items.find((item) => item.id === bookmark.id) || bookmark;
const isDefaultBookmark = stateBookmark && stateBookmark.isDefault;
const nowIso = new Date().toISOString();
if (isDefaultBookmark) {
bookmarkState.defaultLastClickedAt = nowIso;
try {
localStorage.setItem(DEFAULT_BOOKMARK_LAST_CLICK_KEY, nowIso);
} catch (error) {
console.warn('Konnte Standard-Bookmark-Zeit nicht speichern:', error);
}
renderBookmarks();
} else if (stateBookmark && stateBookmark.id && stateBookmark.deletable !== false) {
upsertBookmarkInState({
id: stateBookmark.id,
label: stateBookmark.label,
query: stateBookmark.query,
last_clicked_at: nowIso,
created_at: stateBookmark.created_at || nowIso,
updated_at: nowIso
});
renderBookmarks();
markBookmarkClick(stateBookmark.id);
}
queries.forEach((searchTerm) => {
const url = buildBookmarkSearchUrl(searchTerm);
if (url) {
window.open(url, '_blank', 'noopener');
}
});
}
async function markBookmarkClick(bookmarkId) {
if (!bookmarkId) {
return;
}
try {
const response = await apiFetch(`${API_URL}/bookmarks/${encodeURIComponent(bookmarkId)}/click`, {
method: 'POST'
});
if (!response.ok) {
return;
}
const updated = await response.json();
upsertBookmarkInState(updated);
renderBookmarks();
} catch (error) {
console.warn('Konnte Bookmark-Klick nicht speichern:', error);
}
}
async function removeBookmark(bookmarkId) {
if (!bookmarkId) {
return;
}
try {
const response = await apiFetch(`${API_URL}/bookmarks/${encodeURIComponent(bookmarkId)}`, {
method: 'DELETE'
});
if (response.ok || response.status === 204 || response.status === 404) {
removeBookmarkFromState(bookmarkId);
bookmarkState.error = null;
renderBookmarks();
return;
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
console.warn('Konnte Bookmark nicht löschen:', error);
bookmarkState.error = 'Bookmark konnte nicht gelöscht werden.';
renderBookmarks();
}
}
function createBookmarkRow(bookmark) {
const row = document.createElement('div');
row.className = 'bookmark-row';
row.dataset.query = bookmark.query || '';
if (!bookmark.last_clicked_at) {
row.dataset.state = 'never-used';
}
if (bookmark.isDefault) {
row.dataset.default = '1';
}
const openButton = document.createElement('button');
openButton.type = 'button';
openButton.className = 'bookmark-row__open';
const searchVariants = buildBookmarkSearchQueries(bookmark.query);
if (searchVariants.length) {
openButton.title = searchVariants.map((variant) => `${variant}`).join('\n');
}
openButton.addEventListener('click', () => openBookmark(bookmark));
const label = document.createElement('span');
label.className = 'bookmark-row__label';
label.textContent = bookmark.label || bookmark.query || 'Bookmark';
openButton.appendChild(label);
const query = document.createElement('span');
query.className = 'bookmark-row__query';
query.textContent = bookmark.query ? `${bookmark.query}` : 'Standard-Keywords';
openButton.appendChild(query);
row.appendChild(openButton);
const meta = document.createElement('span');
meta.className = 'bookmark-row__meta';
meta.textContent = formatRelativeTimeFromNow(bookmark.last_clicked_at);
if (bookmark.last_clicked_at) {
const date = new Date(bookmark.last_clicked_at);
if (!Number.isNaN(date.getTime())) {
meta.title = date.toLocaleString();
}
}
row.appendChild(meta);
if (bookmark.deletable !== false) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'bookmark-row__remove';
removeBtn.setAttribute('aria-label', `${bookmark.label || bookmark.query} entfernen`);
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
removeBookmark(bookmark.id);
});
row.appendChild(removeBtn);
}
return row;
}
function renderBookmarks() {
if (!bookmarksList) {
return;
}
bookmarksList.innerHTML = '';
if (bookmarkState.loading && !bookmarkState.loaded) {
const loading = document.createElement('div');
loading.className = 'bookmark-status bookmark-status--loading';
loading.textContent = 'Lade Bookmarks...';
bookmarksList.appendChild(loading);
return;
}
if (bookmarkState.error && !bookmarkState.loaded) {
const errorNode = document.createElement('div');
errorNode.className = 'bookmark-status bookmark-status--error';
errorNode.textContent = bookmarkState.error;
bookmarksList.appendChild(errorNode);
return;
}
if (bookmarkState.error && bookmarkState.loaded) {
const warnNode = document.createElement('div');
warnNode.className = 'bookmark-status bookmark-status--error';
warnNode.textContent = bookmarkState.error;
bookmarksList.appendChild(warnNode);
}
const dynamicBookmarks = bookmarkState.items;
const staticDefault = {
id: 'default-search',
label: 'Gewinnspiel / gewinnen / verlosen',
query: '',
last_clicked_at: bookmarkState.defaultLastClickedAt || null,
deletable: false,
isDefault: true
};
const filteredBookmarks = filterBookmarksBySearch(dynamicBookmarks);
const sortedForAll = sortBookmarksForDisplay(filteredBookmarks);
const displayList = bookmarkSearchTerm ? sortedForAll : [staticDefault, ...sortedForAll];
const titleText = bookmarkSearchTerm
? (filteredBookmarks.length ? `Suchergebnisse (${filteredBookmarks.length})` : 'Keine Treffer')
: 'Alle Bookmarks';
if (!displayList.length) {
const empty = document.createElement('div');
empty.className = 'bookmark-empty';
if (bookmarkSearchTerm) {
empty.textContent = `Keine Bookmarks gefunden für „${bookmarkSearchTerm}“.`;
} else {
empty.textContent = 'Noch keine Bookmarks gespeichert.';
}
bookmarksList.appendChild(empty);
return;
}
const sectionElement = document.createElement('section');
sectionElement.className = 'bookmark-section';
sectionElement.dataset.section = bookmarkSearchTerm ? 'search' : 'all';
const headerEl = document.createElement('header');
headerEl.className = 'bookmark-section__header';
const titleEl = document.createElement('h3');
titleEl.className = 'bookmark-section__title';
titleEl.textContent = titleText;
headerEl.appendChild(titleEl);
sectionElement.appendChild(headerEl);
const list = document.createElement('div');
list.className = 'bookmark-section__list';
displayList.forEach((bookmark) => {
list.appendChild(createBookmarkRow(bookmark));
});
sectionElement.appendChild(list);
bookmarksList.appendChild(sectionElement);
}
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.style.display = bookmarkPanelVisible ? 'flex' : 'none';
bookmarkPanel.setAttribute('aria-hidden', bookmarkPanelVisible ? 'false' : 'true');
bookmarkPanelToggle.setAttribute('aria-expanded', bookmarkPanelVisible ? 'true' : 'false');
if (bookmarkPanelVisible) {
if (!bookmarkState.loaded && !bookmarkState.loading) {
bookmarkState.loading = true;
renderBookmarks();
refreshBookmarks().catch(() => {});
} else {
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();
bookmarkState.error = null;
if (bookmarkPanelToggle) {
bookmarkPanelToggle.focus();
}
}
}
async 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;
}
bookmarkState.error = null;
try {
const response = await apiFetch(`${API_URL}/bookmarks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
label: name,
query
})
});
if (response.status === 409) {
bookmarkState.error = 'Bookmark existiert bereits.';
renderBookmarks();
resetBookmarkForm();
if (bookmarkQueryInput) {
bookmarkQueryInput.focus();
}
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const created = await response.json();
upsertBookmarkInState(created);
bookmarkState.error = null;
renderBookmarks();
resetBookmarkForm();
} catch (error) {
console.warn('Konnte Bookmark nicht speichern:', error);
bookmarkState.error = 'Bookmark konnte nicht gespeichert werden.';
renderBookmarks();
}
}
function initializeBookmarks() {
if (!bookmarksList) {
return;
}
refreshBookmarks().catch(() => {});
if (bookmarkPanel) {
bookmarkPanel.setAttribute('aria-hidden', 'true');
bookmarkPanel.hidden = true;
bookmarkPanel.style.display = 'none';
}
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);
}
if (bookmarkSearchInput) {
bookmarkSearchInput.value = bookmarkSearchTerm;
bookmarkSearchInput.addEventListener('input', () => {
bookmarkSearchTerm = typeof bookmarkSearchInput.value === 'string'
? bookmarkSearchInput.value.trim()
: '';
persistBookmarkPreferences();
renderBookmarks();
});
}
if (bookmarkSortSelect) {
bookmarkSortSelect.value = bookmarkSortMode;
bookmarkSortSelect.addEventListener('change', () => {
const value = bookmarkSortSelect.value;
if (value === 'label' || value === 'recent') {
bookmarkSortMode = value;
persistBookmarkPreferences();
renderBookmarks();
}
});
}
if (bookmarkSortDirectionToggle) {
bookmarkSortDirectionToggle.addEventListener('click', () => {
bookmarkSortDirection = bookmarkSortDirection === 'desc' ? 'asc' : 'desc';
updateBookmarkSortDirectionUI();
persistBookmarkPreferences();
renderBookmarks();
});
updateBookmarkSortDirectionUI();
}
}
function getSortSettingsPageKey() {
try {
const path = window.location.pathname;
if (typeof path === 'string' && path) {
return path;
}
} catch (error) {
// ignore and fall back
}
return 'default';
}
function updateSortDirectionToggleUI() {
if (!sortDirectionToggle) {
return;
}
const isAsc = sortDirection === 'asc';
sortDirectionToggle.setAttribute('aria-pressed', isAsc ? 'true' : 'false');
sortDirectionToggle.setAttribute('aria-label', isAsc ? 'Aufsteigend' : 'Absteigend');
sortDirectionToggle.title = isAsc ? 'Aufsteigend' : 'Absteigend';
sortDirectionToggle.dataset.direction = sortDirection;
const icon = sortDirectionToggle.querySelector('.sort-direction-toggle__icon');
if (icon) {
icon.textContent = isAsc ? '▲' : '▼';
} else {
sortDirectionToggle.textContent = isAsc ? '▲' : '▼';
}
}
function normalizeRequiredProfiles(post) {
if (Array.isArray(post.required_profiles) && post.required_profiles.length) {
return post.required_profiles
.map((value) => {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) {
return null;
}
return Math.min(MAX_PROFILES, Math.max(1, parsed));
})
.filter(Boolean);
}
const parsedTarget = parseInt(post.target_count, 10);
const count = Number.isNaN(parsedTarget)
? 1
: Math.min(MAX_PROFILES, Math.max(1, parsedTarget));
return Array.from({ length: count }, (_, index) => index + 1);
}
function updateTabButtons() {
document.querySelectorAll('.tab-btn').forEach((button) => {
if (!button.dataset.tab) {
return;
}
button.classList.toggle('active', button.dataset.tab === currentTab);
});
}
function updateTabInUrl() {
const url = new URL(window.location.href);
if (currentTab === 'pending') {
url.searchParams.set('tab', 'pending');
} else if (currentTab === 'expired') {
url.searchParams.set('tab', 'expired');
} else {
url.searchParams.set('tab', 'all');
}
window.history.replaceState({}, document.title, `${url.pathname}?${url.searchParams.toString()}${url.hash}`);
}
function getTabKey(tab = currentTab) {
if (tab === 'expired') {
return 'expired';
}
if (tab === 'all') {
return 'all';
}
return 'pending';
}
function getVisibleCount(tab = currentTab) {
const key = getTabKey(tab);
const value = tabVisibleCounts[key];
const normalized = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : INITIAL_POST_LIMIT;
return Math.max(INITIAL_POST_LIMIT, normalized);
}
function setVisibleCount(tab, count) {
const key = getTabKey(tab);
const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : INITIAL_POST_LIMIT;
tabVisibleCounts[key] = Math.max(INITIAL_POST_LIMIT, normalized);
}
function resetVisibleCount(tab = currentTab) {
setVisibleCount(tab, INITIAL_POST_LIMIT);
}
function updateFilteredCount(tab, count) {
const key = getTabKey(tab);
tabFilteredCounts[key] = Math.max(0, count || 0);
}
function cleanupLoadMoreObserver() {
if (loadMoreObserver && observedLoadMoreElement) {
loadMoreObserver.unobserve(observedLoadMoreElement);
observedLoadMoreElement = null;
}
}
function getTabDisplayLabel(tab = currentTab) {
if (tab === 'expired') {
return 'Abgelaufen/Abgeschlossen';
}
if (tab === 'all') {
return 'Alle Beiträge';
}
return 'Offene Beiträge';
}
function buildPostsSummary({
tab,
visibleCount,
filteredCount,
tabTotalCount,
totalCountAll,
searchActive
}) {
const label = getTabDisplayLabel(tab);
const segments = [];
segments.push(`<span class="posts-summary__item">Angezeigt: <strong>${visibleCount}</strong> von ${filteredCount}</span>`);
if (searchActive) {
segments.push(`<span class="posts-summary__item">Treffer: <strong>${filteredCount}</strong> von ${tabTotalCount}</span>`);
}
segments.push(`<span class="posts-summary__item">Tab gesamt: <strong>${tabTotalCount}</strong></span>`);
segments.push(`<span class="posts-summary__item">Alle Beiträge: <strong>${totalCountAll}</strong></span>`);
return `
<div class="posts-summary" role="status" aria-live="polite">
<span class="posts-summary__label">${label}</span>
${segments.join('<span class="posts-summary__separator">·</span>')}
</div>
`;
}
function ensureLoadMoreObserver() {
if (loadMoreObserver) {
return loadMoreObserver;
}
loadMoreObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
const element = entry.target;
const tab = element && element.dataset ? element.dataset.tab : currentTab;
if (loadMoreObserver) {
loadMoreObserver.unobserve(element);
}
loadMorePosts(tab, { triggeredByScroll: true });
});
}, {
root: null,
rootMargin: '200px 0px',
threshold: 0.1
});
return loadMoreObserver;
}
function observeLoadMoreElement(element, tab) {
if (!element) {
return;
}
const observer = ensureLoadMoreObserver();
observedLoadMoreElement = element;
element.dataset.tab = getTabKey(tab);
observer.observe(element);
}
function loadMorePosts(tab = currentTab, { triggeredByScroll = false } = {}) {
const key = getTabKey(tab);
const total = tabFilteredCounts[key] || 0;
const currentLimit = getVisibleCount(tab);
if (currentLimit >= total) {
return;
}
const newLimit = Math.min(total, currentLimit + POST_LOAD_INCREMENT);
setVisibleCount(tab, newLimit);
if (tab === currentTab) {
renderPosts();
}
}
function setTab(tab, { updateUrl = true } = {}) {
if (tab === 'all') {
currentTab = 'all';
} else if (tab === 'expired') {
currentTab = 'expired';
} else {
currentTab = 'pending';
}
updateTabButtons();
loadSortMode({ fromTabChange: true });
if (updateUrl) {
updateTabInUrl();
}
renderPosts();
}
function initializeTabFromUrl() {
let tabResolved = false;
try {
const params = new URLSearchParams(window.location.search);
const tabParam = params.get('tab');
if (tabParam === 'all' || tabParam === 'pending' || tabParam === 'expired') {
currentTab = tabParam;
tabResolved = true;
} else if (initialTabOverride) {
currentTab = initialTabOverride;
tabResolved = true;
} else if (initialViewParam === 'dashboard') {
currentTab = 'pending';
tabResolved = true;
}
} catch (error) {
console.warn('Konnte Tab-Parameter nicht auslesen:', error);
}
updateTabButtons();
if (tabResolved) {
updateTabInUrl();
}
}
function normalizeDeadlineInput(value) {
if (!value) {
return null;
}
try {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString();
} catch (error) {
console.warn('Ungültige Deadline-Eingabe:', error);
return null;
}
}
function toDateTimeLocalValue(value) {
if (!value) {
return '';
}
try {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
} catch (error) {
console.warn('Kann Deadline nicht für Eingabe formatieren:', error);
return '';
}
}
function normalizeFacebookPostUrl(rawValue) {
if (typeof rawValue !== 'string') {
return null;
}
let value = rawValue.trim();
if (!value) {
return null;
}
const trackingIndex = value.indexOf('__cft__');
if (trackingIndex !== -1) {
value = value.slice(0, trackingIndex);
}
value = value.replace(/[?&]$/, '');
let parsed;
try {
parsed = new URL(value);
} catch (error) {
try {
parsed = new URL(value, window.location.origin);
} catch (innerError) {
try {
parsed = new URL(value, 'https://www.facebook.com');
} catch (fallbackError) {
return null;
}
}
}
if (!parsed.hostname.toLowerCase().endsWith('facebook.com')) {
return null;
}
const normalizedPathBeforeTrim = parsed.pathname.replace(/\/+$/, '') || '/';
const lowerPathBeforeTrim = normalizedPathBeforeTrim.toLowerCase();
const watchId = parsed.searchParams.get('v') || parsed.searchParams.get('video_id');
if ((lowerPathBeforeTrim === '/watch' || lowerPathBeforeTrim === '/video.php') && watchId) {
parsed.pathname = `/reel/${watchId}/`;
parsed.search = '';
} else {
const reelMatch = lowerPathBeforeTrim.match(/^\/reel\/([^/]+)$/);
if (reelMatch) {
parsed.pathname = `/reel/${reelMatch[1]}/`;
parsed.search = '';
}
}
const cleanedParams = new URLSearchParams();
parsed.searchParams.forEach((paramValue, paramKey) => {
const lowerKey = paramKey.toLowerCase();
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);
});
const normalizedPath = parsed.pathname.replace(/\/+$/, '').toLowerCase();
if (normalizedPath.startsWith('/hashtag/') || normalizedPath.startsWith('/watch/hashtag/')) {
return '';
}
const search = cleanedParams.toString();
const formatted = `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ''}`;
return formatted.replace(/[?&]$/, '');
}
function getDeadlinePartsFromValue(value) {
const localValue = toDateTimeLocalValue(value);
if (!localValue) {
return {
date: '',
time: ''
};
}
const [datePart, timePart = ''] = localValue.split('T');
return {
date: datePart,
time: timePart
};
}
function getDefaultDeadlineParts() {
const tomorrow = new Date();
tomorrow.setHours(0, 0, 0, 0);
tomorrow.setDate(tomorrow.getDate() + 1);
const localIso = new Date(tomorrow.getTime() - tomorrow.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16);
const [datePart, timePart = '00:00'] = localIso.split('T');
return {
date: datePart,
time: timePart || '00:00'
};
}
function getDefaultDeadlineInputValue() {
const { date, time } = getDefaultDeadlineParts();
if (!date) {
return '';
}
return `${date}T${time || '00:00'}`;
}
function positionDeadlinePicker(picker, triggerElement) {
if (!picker || !triggerElement) {
return;
}
const rect = triggerElement.getBoundingClientRect();
const pickerRect = picker.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const safeMargin = 16;
const offset = 8;
let left = rect.left;
let top = rect.bottom + offset;
if (left + pickerRect.width + safeMargin > viewportWidth) {
left = viewportWidth - pickerRect.width - safeMargin;
}
if (left < safeMargin) {
left = safeMargin;
}
if (top + pickerRect.height + safeMargin > viewportHeight) {
top = rect.top - pickerRect.height - offset;
if (top < safeMargin) {
top = viewportHeight - pickerRect.height - safeMargin;
}
}
if (top < safeMargin) {
top = safeMargin;
}
picker.style.left = `${Math.round(left)}px`;
picker.style.top = `${Math.round(top)}px`;
}
function closeActiveDeadlinePicker() {
if (!activeDeadlinePicker) {
return;
}
const current = activeDeadlinePicker;
activeDeadlinePicker = null;
try {
if (typeof current.destroy === 'function') {
current.destroy();
return;
}
const { input } = current;
if (input) {
input.removeEventListener('change', current.onChange);
input.removeEventListener('blur', current.onBlur);
input.remove();
}
} catch (error) {
console.warn('Konnte Deadline-Picker nicht schließen:', error);
}
}
function openNativeDeadlinePicker(post, triggerElement) {
if (!post || !triggerElement) {
return;
}
closeActiveDeadlinePicker();
const picker = document.createElement('div');
picker.className = 'deadline-picker';
picker.setAttribute('role', 'dialog');
picker.setAttribute('aria-modal', 'true');
picker.setAttribute('tabindex', '-1');
picker.innerHTML = `
<div class="deadline-picker__header">
<span class="deadline-picker__title">Deadline anpassen</span>
<button type="button" class="deadline-picker__close" aria-label="Schließen">×</button>
</div>
<form class="deadline-picker__form" novalidate>
<div class="deadline-picker__field">
<label>
<span>Datum</span>
<input type="date" class="deadline-picker__date" required>
</label>
</div>
<div class="deadline-picker__field">
<label>
<span>Uhrzeit</span>
<input type="time" class="deadline-picker__time" step="900" placeholder="hh:mm">
</label>
</div>
<div class="deadline-picker__hint">Uhrzeit optional Standard ist 00:00 Uhr.</div>
<div class="deadline-picker__error" aria-live="polite"></div>
<div class="deadline-picker__actions">
<button type="submit" class="btn btn-primary deadline-picker__save">Speichern</button>
<button type="button" class="btn btn-secondary deadline-picker__cancel">Abbrechen</button>
</div>
</form>
`;
const form = picker.querySelector('.deadline-picker__form');
const dateInput = picker.querySelector('.deadline-picker__date');
const timeInput = picker.querySelector('.deadline-picker__time');
const errorEl = picker.querySelector('.deadline-picker__error');
const cancelButton = picker.querySelector('.deadline-picker__cancel');
const closeButton = picker.querySelector('.deadline-picker__close');
const saveButton = picker.querySelector('.deadline-picker__save');
const originalSaveLabel = saveButton ? saveButton.textContent : '';
const initialValues = { date: '', time: '' };
const applyInitialValues = () => {
const existing = getDeadlinePartsFromValue(post.deadline_at);
const defaults = existing.date ? existing : getDefaultDeadlineParts();
initialValues.date = defaults.date;
initialValues.time = defaults.time || '';
if (dateInput) {
dateInput.value = defaults.date;
}
if (timeInput) {
timeInput.value = defaults.time;
}
};
applyInitialValues();
document.body.appendChild(picker);
positionDeadlinePicker(picker, triggerElement);
const showError = (message = '') => {
if (!errorEl) {
return;
}
errorEl.textContent = message;
errorEl.classList.toggle('is-visible', Boolean(message));
};
let isSaving = false;
const normalizeParts = (datePart, timePart) => {
if (!datePart) {
return '';
}
return `${datePart}T${(timePart || '00:00')}`;
};
const attemptAutoSave = () => {
if (isSaving || !form || !dateInput) {
return false;
}
const currentDate = dateInput.value;
if (!currentDate) {
return false;
}
const currentTime = timeInput ? timeInput.value : '';
const currentNormalized = normalizeParts(currentDate, currentTime);
const initialNormalized = normalizeParts(initialValues.date, initialValues.time);
if (currentNormalized === initialNormalized) {
return false;
}
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}
return true;
};
const handleSubmit = async (event) => {
event.preventDefault();
if (isSaving || !dateInput || !saveButton) {
return;
}
showError('');
const dateValue = dateInput.value;
if (!dateValue) {
showError('Bitte ein Datum auswählen.');
dateInput.focus();
return;
}
const timeValue = (timeInput && timeInput.value) ? timeInput.value : '00:00';
const combined = `${dateValue}T${timeValue || '00:00'}`;
const normalized = normalizeDeadlineInput(combined);
if (!normalized) {
showError('Ungültige Eingabe.');
return;
}
try {
isSaving = true;
saveButton.disabled = true;
saveButton.textContent = 'Speichert…';
const success = await saveDeadline(post.id, normalized);
if (success) {
closeActiveDeadlinePicker();
return;
}
showError('Deadline wurde nicht gespeichert.');
} catch (error) {
console.warn('Deadline konnte nicht gespeichert werden:', error);
showError('Konnte Deadline nicht speichern.');
} finally {
isSaving = false;
saveButton.disabled = false;
saveButton.textContent = originalSaveLabel;
}
};
form?.addEventListener('submit', handleSubmit);
const cleanupListeners = [];
const pickerState = {
destroy: () => {
while (cleanupListeners.length) {
const clean = cleanupListeners.pop();
try {
clean();
} catch (error) {
console.warn('Konnte Listener nicht entfernen:', error);
}
}
picker.remove();
}
};
const registerListener = (target, type, listener, options) => {
if (!target) {
return;
}
target.addEventListener(type, listener, options);
cleanupListeners.push(() => target.removeEventListener(type, listener, options));
};
const closePicker = () => {
if (activeDeadlinePicker) {
closeActiveDeadlinePicker();
return;
}
pickerState.destroy();
};
registerListener(cancelButton, 'click', (event) => {
event.preventDefault();
closePicker();
});
registerListener(closeButton, 'click', (event) => {
event.preventDefault();
closePicker();
});
const handleOutsidePointer = (event) => {
const targetElement = event.target instanceof Element ? event.target : null;
const isClearButton = targetElement?.closest('.post-deadline__clear');
if (!picker.contains(event.target) && !triggerElement.contains(event.target)) {
if (!isClearButton && attemptAutoSave()) {
event.preventDefault();
event.stopPropagation();
return;
}
closePicker();
}
};
const handleKeydown = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
closePicker();
}
};
const reposition = () => {
positionDeadlinePicker(picker, triggerElement);
};
registerListener(document, 'pointerdown', handleOutsidePointer, true);
registerListener(document, 'keydown', handleKeydown);
registerListener(window, 'resize', reposition);
registerListener(window, 'scroll', reposition, true);
activeDeadlinePicker = pickerState;
requestAnimationFrame(() => {
positionDeadlinePicker(picker, triggerElement);
requestAnimationFrame(() => {
dateInput?.focus({ preventScroll: true });
});
});
}
async function clearDeadline(postId) {
closeActiveDeadlinePicker();
await saveDeadline(postId, null);
}
function normalizeChecks(checks) {
if (!Array.isArray(checks)) {
return [];
}
return checks
.map((check) => {
if (!check) {
return null;
}
const parsed = parseInt(check.profile_number, 10);
if (Number.isNaN(parsed)) {
return null;
}
const profileNumber = Math.min(MAX_PROFILES, Math.max(1, parsed));
return {
...check,
profile_number: profileNumber,
profile_name: check.profile_name || getProfileName(profileNumber),
checked_at: check.checked_at || null
};
})
.filter(Boolean)
.sort((a, b) => {
const aTime = a.checked_at ? new Date(a.checked_at).getTime() : 0;
const bTime = b.checked_at ? new Date(b.checked_at).getTime() : 0;
if (aTime === bTime) {
return a.profile_number - b.profile_number;
}
return aTime - bTime;
});
}
function calculateUrgencyScore(postItem) {
const post = postItem.post;
const status = postItem.status;
// Remaining participations needed
const remaining = status.targetCount - status.checkedCount;
if (remaining <= 0) return 999999; // Completed posts go to bottom
const now = Date.now();
const createdAt = post.created_at ? new Date(post.created_at).getTime() : now;
// Time until deadline (in hours)
let hoursUntilDeadline = Infinity;
let totalDuration = Infinity;
if (post.deadline_at) {
const deadline = new Date(post.deadline_at).getTime();
hoursUntilDeadline = Math.max(0, (deadline - now) / (1000 * 60 * 60));
totalDuration = Math.max(1, (deadline - createdAt) / (1000 * 60 * 60)); // Total hours from creation to deadline
}
// Time since last participation (in hours)
let hoursSinceLastCheck = Infinity;
if (status.lastCheckedAt) {
const lastCheck = new Date(status.lastCheckedAt).getTime();
hoursSinceLastCheck = (now - lastCheck) / (1000 * 60 * 60);
}
// Calculate ideal pace: how often should participations happen?
let idealIntervalHours = Infinity;
let behindSchedule = false;
if (totalDuration < Infinity && status.targetCount > 0) {
// Ideal interval between participations
idealIntervalHours = totalDuration / status.targetCount;
// Expected participations by now (based on time elapsed)
const hoursElapsed = (now - createdAt) / (1000 * 60 * 60);
const expectedChecks = Math.floor(hoursElapsed / idealIntervalHours);
// Are we behind schedule?
behindSchedule = status.checkedCount < expectedChecks;
}
// Calculate urgency score (lower = more urgent)
let score = 0;
// For posts with deadline: calculate when the next participation should ideally happen
if (hoursUntilDeadline < Infinity && remaining > 0) {
// How many hours do we have per remaining participation?
const hoursPerParticipation = hoursUntilDeadline / remaining;
// When should the next participation ideally happen?
let hoursUntilNextIdeal = hoursPerParticipation;
// If we have a last check, calculate from there
if (hoursSinceLastCheck < Infinity) {
hoursUntilNextIdeal = Math.max(0, hoursPerParticipation - hoursSinceLastCheck);
}
// Score based on how soon the next participation is due
// Posts that are overdue or due soon get higher priority
if (hoursUntilNextIdeal <= 0) {
// Overdue! High priority
score = Math.abs(hoursUntilNextIdeal) * -10; // Negative score = very high priority
} else if (hoursUntilNextIdeal <= 24) {
// Due within 24h
score = hoursUntilNextIdeal * 10; // 0-240
} else if (hoursUntilNextIdeal <= 72) {
// Due within 3 days
score = 240 + (hoursUntilNextIdeal - 24) * 20; // 240-1200
} else {
// Due later
score = 1200 + (hoursUntilNextIdeal - 72) * 50; // 1200+
}
// Emergency boost: if deadline is very close, override everything
if (hoursUntilDeadline <= 24 && remaining > 0) {
score = Math.min(score, hoursUntilDeadline * 5); // Max 120 points
} else if (hoursUntilDeadline <= 48 && remaining > 1) {
score = Math.min(score, 120 + (hoursUntilDeadline - 24) * 10);
}
} else if (hoursUntilDeadline < Infinity && remaining === 0) {
// Completed with deadline
score = 100000;
} else {
// No deadline - use simpler heuristic
score = 50000;
// Prioritize posts that haven't been checked in a while
if (hoursSinceLastCheck < Infinity) {
if (hoursSinceLastCheck < 24) {
score += 50; // Recently checked = lower priority
} else if (hoursSinceLastCheck > 72) {
score -= 100; // Long time since check = higher priority
}
}
}
// Recent participation (<24h) should lower urgency regardless of deadline pressure
if (hoursSinceLastCheck < 24) {
const freshnessRatio = (24 - hoursSinceLastCheck) / 24; // 0 (at 24h) to 1 (just now)
const recencyPenalty = 60 + Math.round(freshnessRatio * 120); // 60-180 point penalty
score += recencyPenalty;
}
// Give extra priority to posts with deadlines approaching soon
if (hoursUntilDeadline < Infinity && remaining > 0) {
const urgencyWindowHours = 72;
const cappedHours = Math.min(hoursUntilDeadline, urgencyWindowHours);
const closenessRatio = 1 - (cappedHours / urgencyWindowHours); // 0-1
if (closenessRatio > 0) {
const baseBoost = Math.round(closenessRatio * 120); // Up to 120 points
const remainingFactor = Math.min(1.6, 0.7 + remaining * 0.3); // 1.0 (1 remaining) .. 1.6 (>=3 remaining)
const deadlineBoost = Math.round(baseBoost * remainingFactor);
score -= deadlineBoost;
}
}
return score;
}
function comparePostItems(a, b) {
const postA = a.post;
const postB = b.post;
const createdA = toTimestamp(postA.created_at, 0);
const createdB = toTimestamp(postB.created_at, 0);
let comparison = 0;
if (sortMode === 'deadline') {
const deadlineA = toTimestamp(postA.deadline_at, Infinity);
const deadlineB = toTimestamp(postB.deadline_at, Infinity);
if (deadlineA !== deadlineB) {
comparison = deadlineA - deadlineB;
}
} else if (sortMode === 'smart') {
// Smart sorting based on urgency
const scoreA = calculateUrgencyScore(a);
const scoreB = calculateUrgencyScore(b);
if (scoreA !== scoreB) {
comparison = scoreB - scoreA; // Higher score = lower priority (inverted for intuitive direction)
}
} else if (sortMode === 'lastChange') {
const changeA = Number.isFinite(a.status.lastChangeTimestamp)
? a.status.lastChangeTimestamp
: toTimestamp(postA.created_at, 0);
const changeB = Number.isFinite(b.status.lastChangeTimestamp)
? b.status.lastChangeTimestamp
: toTimestamp(postB.created_at, 0);
if (changeA !== changeB) {
comparison = changeA - changeB;
}
} else if (sortMode === 'lastCheck') {
const lastA = Number.isFinite(a.status.lastCheckedTimestamp)
? a.status.lastCheckedTimestamp
: Number.NEGATIVE_INFINITY;
const lastB = Number.isFinite(b.status.lastCheckedTimestamp)
? b.status.lastCheckedTimestamp
: Number.NEGATIVE_INFINITY;
if (lastA !== lastB) {
comparison = lastA - lastB;
}
} else if (createdA !== createdB) {
comparison = createdA - createdB;
}
if (comparison === 0 && createdA !== createdB) {
comparison = createdA - createdB;
}
if (comparison === 0) {
const idA = String(postA.id || '');
const idB = String(postB.id || '');
if (idA !== idB) {
comparison = idA < idB ? -1 : 1;
}
}
if (comparison === 0) {
return 0;
}
const multiplier = sortDirection === 'asc' ? 1 : -1;
return comparison * multiplier;
}
function displayManualPostMessage(message, type = 'success') {
if (!manualPostMessage) {
return;
}
manualPostMessage.textContent = message;
manualPostMessage.classList.remove('error', 'success');
manualPostMessage.classList.add(type);
}
function clearManualPostMessage() {
if (!manualPostMessage) {
return;
}
manualPostMessage.textContent = '';
manualPostMessage.classList.remove('error', 'success');
}
function openManualPostModal({ mode = 'create', post = null, focus = null } = {}) {
if (!manualPostModal || !manualPostForm) {
return;
}
manualPostModalLastFocus = document.activeElement && typeof document.activeElement.blur === 'function'
? document.activeElement
: null;
resetManualPostForm({ keepMessages: false });
manualPostMode = mode === 'edit' ? 'edit' : 'create';
manualPostEditingId = manualPostMode === 'edit' && post ? post.id : null;
const desiredFocus = typeof focus === 'string' ? focus : (manualPostMode === 'edit' ? 'title' : 'url');
if (manualPostModalTitle) {
manualPostModalTitle.textContent = manualPostMode === 'edit'
? 'Beitrag bearbeiten'
: 'Beitrag hinzufügen';
}
if (manualPostSubmitButton) {
manualPostSubmitButton.textContent = manualPostMode === 'edit'
? 'Aktualisieren'
: 'Speichern';
}
if (manualPostMode === 'edit' && post) {
populateManualPostForm(post);
}
manualPostModal.removeAttribute('hidden');
manualPostModal.classList.add('open');
manualPostModalPreviousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
requestAnimationFrame(() => {
if (manualPostModalContent) {
manualPostModalContent.focus();
}
if (desiredFocus === 'deadline' && manualPostDeadlineInput) {
manualPostDeadlineInput.focus();
manualPostDeadlineInput.select?.();
return;
}
if (desiredFocus === 'title' && manualPostTitleInput) {
manualPostTitleInput.focus();
manualPostTitleInput.select();
return;
}
if (desiredFocus === 'url' && manualPostUrlInput) {
manualPostUrlInput.focus();
manualPostUrlInput.select?.();
return;
}
if (manualPostMode === 'edit' && manualPostTitleInput) {
manualPostTitleInput.focus();
manualPostTitleInput.select();
} else if (manualPostUrlInput) {
manualPostUrlInput.focus();
}
});
}
function closeManualPostModal() {
if (!manualPostModal) {
return;
}
manualPostModal.classList.remove('open');
manualPostModal.setAttribute('hidden', '');
resetManualPostForm();
document.body.style.overflow = manualPostModalPreviousOverflow;
if (manualPostModalLastFocus && typeof manualPostModalLastFocus.focus === 'function') {
manualPostModalLastFocus.focus();
}
}
function loadAutoRefreshSettings() {
try {
const stored = localStorage.getItem(REFRESH_SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (typeof parsed === 'object' && parsed) {
if (typeof parsed.enabled === 'boolean') {
autoRefreshSettings.enabled = parsed.enabled;
}
if (typeof parsed.interval === 'number' && parsed.interval >= 5000) {
autoRefreshSettings.interval = parsed.interval;
}
}
}
} catch (error) {
console.warn('Konnte Refresh-Einstellungen nicht laden:', error);
}
if (autoRefreshToggle) {
autoRefreshToggle.checked = autoRefreshSettings.enabled;
}
if (autoRefreshIntervalSelect) {
autoRefreshIntervalSelect.value = String(autoRefreshSettings.interval);
autoRefreshIntervalSelect.disabled = !autoRefreshSettings.enabled;
}
}
function saveAutoRefreshSettings() {
try {
localStorage.setItem(REFRESH_SETTINGS_KEY, JSON.stringify(autoRefreshSettings));
} catch (error) {
console.warn('Konnte Refresh-Einstellungen nicht speichern:', error);
}
}
function applyAutoRefreshSettings() {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
if (autoRefreshIntervalSelect) {
const disabled = !autoRefreshSettings.enabled || updatesStreamHealthy;
autoRefreshIntervalSelect.disabled = disabled;
autoRefreshIntervalSelect.title = updatesStreamHealthy
? 'Live-Updates sind aktiv; das Intervall wird aktuell nicht verwendet.'
: '';
}
if (!autoRefreshSettings.enabled) {
return;
}
if (updatesStreamHealthy) {
return;
}
autoRefreshTimer = setInterval(() => {
if (document.hidden) {
return;
}
fetchPosts({ showLoader: false });
}, autoRefreshSettings.interval);
}
function loadSortMode({ fromTabChange = false } = {}) {
const pageKey = getSortSettingsPageKey();
const tabKey = getSortTabKey();
const storage = getSortStorage();
const pageSettings = storage[pageKey] && typeof storage[pageKey] === 'object' ? storage[pageKey] : {};
const legacyCandidate = (pageSettings.mode || pageSettings.direction)
? { mode: pageSettings.mode, direction: pageSettings.direction }
: null;
const candidates = [pageSettings[tabKey], legacyCandidate, pageSettings.default, storage.default];
let applied = null;
for (const candidate of candidates) {
if (candidate && typeof candidate === 'object') {
applied = candidate;
break;
}
}
if (applied) {
sortMode = normalizeSortMode(applied.mode);
sortDirection = normalizeSortDirection(applied.direction);
} else {
sortMode = normalizeSortMode(sortMode);
sortDirection = normalizeSortDirection(sortDirection);
}
if (sortModeSelect) {
sortModeSelect.value = sortMode;
}
updateSortDirectionToggleUI();
if (!fromTabChange) {
// Ensure structure exists for initial load
saveSortMode();
}
}
function saveSortMode() {
const pageKey = getSortSettingsPageKey();
const tabKey = getSortTabKey();
const storage = getSortStorage();
if (!storage[pageKey] || typeof storage[pageKey] !== 'object') {
storage[pageKey] = {};
}
delete storage[pageKey].mode;
delete storage[pageKey].direction;
storage[pageKey][tabKey] = {
mode: normalizeSortMode(sortMode),
direction: normalizeSortDirection(sortDirection)
};
if (!storage[pageKey].default) {
storage[pageKey].default = { ...DEFAULT_SORT_SETTINGS };
}
if (!storage.default) {
storage.default = { ...DEFAULT_SORT_SETTINGS };
}
persistSortStorage(storage);
}
function computePostStatus(post, profileNumber = currentProfile) {
const requiredProfiles = normalizeRequiredProfiles(post);
const checks = normalizeChecks(post.checks);
let lastCheckedAt = null;
let lastCheckedTimestamp = null;
for (const check of checks) {
if (!check || !check.checked_at) {
continue;
}
const timestamp = new Date(check.checked_at).getTime();
if (Number.isNaN(timestamp)) {
continue;
}
if (lastCheckedTimestamp === null || timestamp > lastCheckedTimestamp) {
lastCheckedTimestamp = timestamp;
lastCheckedAt = check.checked_at;
}
}
const lastChangeTimestamp = toTimestamp(post.last_change, toTimestamp(post.created_at, 0));
const lastChangeAt = post.last_change || post.created_at || null;
// Check if post is expired (deadline passed)
const isExpired = post.deadline_at ? new Date(post.deadline_at) < new Date() : false;
const backendStatuses = Array.isArray(post.profile_statuses) ? post.profile_statuses : [];
let profileStatuses = backendStatuses
.map((status) => {
if (!status) {
return null;
}
const parsed = parseInt(status.profile_number, 10);
if (Number.isNaN(parsed)) {
return null;
}
const profileNumberValue = Math.min(MAX_PROFILES, Math.max(1, parsed));
let normalizedStatus = status.status;
if (normalizedStatus !== 'done' && normalizedStatus !== 'available') {
normalizedStatus = 'locked';
}
const check = checks.find((item) => item.profile_number === profileNumberValue) || null;
return {
profile_number: profileNumberValue,
profile_name: status.profile_name || getProfileName(profileNumberValue),
status: normalizedStatus,
checked_at: status.checked_at || (check ? check.checked_at : null) || null
};
})
.filter(Boolean);
if (profileStatuses.length !== requiredProfiles.length) {
const checksByProfile = new Map(checks.map((check) => [check.profile_number, check]));
const completedSet = new Set(checks.map((check) => check.profile_number));
profileStatuses = requiredProfiles.map((value, index) => {
const prerequisites = requiredProfiles.slice(0, index);
const prerequisitesMet = prerequisites.every((profile) => completedSet.has(profile));
const check = checksByProfile.get(value) || null;
return {
profile_number: value,
profile_name: getProfileName(value),
status: check ? 'done' : (prerequisitesMet ? 'available' : 'locked'),
checked_at: check ? check.checked_at : null
};
});
} else {
const checksByProfile = new Map(checks.map((check) => [check.profile_number, check]));
profileStatuses = requiredProfiles.map((value) => {
const status = profileStatuses.find((item) => item.profile_number === value);
if (!status) {
const check = checksByProfile.get(value) || null;
return {
profile_number: value,
profile_name: getProfileName(value),
status: check ? 'done' : 'locked',
checked_at: check ? check.checked_at : null
};
}
if (status.status === 'done') {
const check = checksByProfile.get(value) || null;
return {
...status,
checked_at: status.checked_at || (check ? check.checked_at : null) || null
};
}
if (status.status === 'available') {
return {
...status,
checked_at: status.checked_at || null
};
}
return {
...status,
status: 'locked',
checked_at: status.checked_at || null
};
});
}
const completedProfilesSet = new Set(
profileStatuses
.filter((status) => status.status === 'done')
.map((status) => status.profile_number)
);
const checkedCount = profileStatuses.filter((status) => status.status === 'done').length;
const targetCount = profileStatuses.length;
const isComplete = profileStatuses.every((status) => status.status === 'done');
const nextRequiredProfile = profileStatuses.find((status) => status.status === 'available') || null;
const isCurrentProfileRequired = requiredProfiles.includes(profileNumber);
const isCurrentProfileDone = profileStatuses.some((status) => status.profile_number === profileNumber && status.status === 'done');
const canCurrentProfileCheck = profileStatuses.some((status) => status.profile_number === profileNumber && status.status === 'available');
const waitingForStatuses = profileStatuses.filter((status) => status.profile_number < profileNumber && status.status !== 'done');
const waitingForProfiles = waitingForStatuses.map((status) => status.profile_number);
const waitingForNames = waitingForStatuses.map((status) => status.profile_name);
return {
requiredProfiles,
profileStatuses,
checks,
lastCheckedAt,
lastCheckedTimestamp,
lastChangeAt,
lastChangeTimestamp,
completedProfilesSet,
checkedCount,
targetCount,
isComplete,
isCurrentProfileRequired,
isCurrentProfileDone,
canCurrentProfileCheck,
waitingForProfiles,
waitingForNames,
nextRequiredProfile,
profileNumber,
isExpired
};
}
function applyScreenshotModalSize() {
if (!screenshotModalContent || !screenshotModalImage) {
return;
}
if (screenshotModalZoomed) {
return;
}
if (!screenshotModalImage.src) {
return;
}
requestAnimationFrame(() => {
const padding = 48;
const viewportWidth = Math.max(320, window.innerWidth * 0.95);
const viewportHeight = Math.max(280, window.innerHeight * 0.92);
const naturalWidth = screenshotModalImage.naturalWidth || screenshotModalImage.width || 0;
const naturalHeight = screenshotModalImage.naturalHeight || screenshotModalImage.height || 0;
const targetWidth = Math.min(Math.max(320, naturalWidth + padding), viewportWidth);
const targetHeight = Math.min(Math.max(260, naturalHeight + padding), viewportHeight);
screenshotModalContent.style.width = `${targetWidth}px`;
screenshotModalContent.style.height = `${targetHeight}px`;
});
}
async function fetchProfileState() {
try {
const response = await apiFetch(`${API_URL}/profile-state`);
if (!response.ok) {
return null;
}
const data = await response.json();
if (data && typeof data.profile_number !== 'undefined') {
const parsed = parseInt(data.profile_number, 10);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return null;
} catch (error) {
console.warn('Profilstatus konnte nicht geladen werden:', error);
return null;
}
}
async function pushProfileState(profileNumber) {
try {
await apiFetch(`${API_URL}/profile-state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_number: profileNumber })
});
} catch (error) {
console.error('Profilstatus konnte nicht gespeichert werden:', error);
}
}
function applyProfileNumber(profileNumber, { fromBackend = false } = {}) {
if (!profileNumber) {
return;
}
if (profileSelectElement) {
profileSelectElement.value = String(profileNumber);
}
if (currentProfile === profileNumber) {
if (!fromBackend) {
pushProfileState(profileNumber);
}
return;
}
currentProfile = profileNumber;
localStorage.setItem('profileNumber', currentProfile);
if (!fromBackend) {
pushProfileState(currentProfile);
}
resetVisibleCount();
renderPosts();
}
// Load profile from localStorage
function loadProfile() {
fetchProfileState().then((backendProfile) => {
if (backendProfile) {
applyProfileNumber(backendProfile, { fromBackend: true });
} else {
const saved = localStorage.getItem('profileNumber');
if (saved) {
applyProfileNumber(parseInt(saved, 10) || 1, { fromBackend: true });
} else {
applyProfileNumber(1, { fromBackend: true });
}
}
});
}
// Save profile to localStorage
function saveProfile(profileNumber) {
applyProfileNumber(profileNumber);
}
function startProfilePolling() {
if (profilePollTimer) {
clearInterval(profilePollTimer);
}
profilePollTimer = setInterval(async () => {
const backendProfile = await fetchProfileState();
if (backendProfile && backendProfile !== currentProfile) {
applyProfileNumber(backendProfile, { fromBackend: true });
}
}, 5000);
}
// Profile selector change handler
if (profileSelectElement) {
profileSelectElement.addEventListener('change', (e) => {
saveProfile(parseInt(e.target.value, 10));
});
}
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
setTab(btn.dataset.tab, { updateUrl: true });
});
});
if (manualPostForm) {
manualPostForm.addEventListener('submit', handleManualPostSubmit);
}
if (manualPostResetButton) {
manualPostResetButton.addEventListener('click', () => {
if (manualPostMode === 'edit' && manualPostEditingId) {
const post = posts.find((item) => item.id === manualPostEditingId);
if (post) {
populateManualPostForm(post);
}
} else {
resetManualPostForm();
if (manualPostUrlInput) {
manualPostUrlInput.focus();
}
}
});
}
if (autoRefreshToggle) {
autoRefreshToggle.addEventListener('change', () => {
autoRefreshSettings.enabled = !!autoRefreshToggle.checked;
saveAutoRefreshSettings();
applyAutoRefreshSettings();
if (autoRefreshSettings.enabled && updatesStreamHealthy) {
console.info('Live-Updates sind aktiv; automatisches Refresh bleibt pausiert.');
}
if (autoRefreshSettings.enabled && !updatesStreamHealthy) {
fetchPosts({ showLoader: false });
}
});
}
if (autoRefreshIntervalSelect) {
autoRefreshIntervalSelect.addEventListener('change', () => {
const value = parseInt(autoRefreshIntervalSelect.value, 10);
if (!Number.isNaN(value) && value >= 5000) {
autoRefreshSettings.interval = value;
saveAutoRefreshSettings();
applyAutoRefreshSettings();
}
});
}
const manualRefreshBtn = document.getElementById('manualRefreshBtn');
if (manualRefreshBtn) {
manualRefreshBtn.addEventListener('click', () => {
fetchPosts();
});
}
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('input', () => {
resetVisibleCount();
renderPosts();
});
}
if (sortModeSelect) {
sortModeSelect.addEventListener('change', () => {
const value = sortModeSelect.value;
sortMode = VALID_SORT_MODES.has(value) ? value : DEFAULT_SORT_SETTINGS.mode;
saveSortMode();
resetVisibleCount();
renderPosts();
});
}
if (sortDirectionToggle) {
sortDirectionToggle.addEventListener('click', () => {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
updateSortDirectionToggleUI();
saveSortMode();
resetVisibleCount();
renderPosts();
});
}
if (openManualPostModalBtn) {
openManualPostModalBtn.addEventListener('click', () => {
openManualPostModal({ mode: 'create' });
});
}
if (manualPostModalClose) {
manualPostModalClose.addEventListener('click', closeManualPostModal);
}
if (manualPostModalBackdrop) {
manualPostModalBackdrop.addEventListener('click', closeManualPostModal);
}
// Fetch all posts
async function fetchPosts({ showLoader = true } = {}) {
if (isFetchingPosts) {
return;
}
isFetchingPosts = true;
try {
if (showLoader) {
showLoading();
}
const response = await apiFetch(`${API_URL}/posts`);
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
const data = await response.json();
posts = Array.isArray(data) ? data : [];
await normalizeLoadedPostUrls();
sortPostsByCreatedAt();
renderPosts();
} catch (error) {
if (showLoader) {
showError('Fehler beim Laden der Beiträge. Stelle sicher, dass das Backend läuft.');
}
console.error('Error fetching posts:', error);
} finally {
if (showLoader) {
hideLoading();
}
isFetchingPosts = false;
}
}
async function normalizeLoadedPostUrls() {
if (!Array.isArray(posts) || !posts.length) {
return false;
}
const candidates = posts
.map((post) => {
if (!post || !post.id || !post.url) {
return null;
}
const cleaned = normalizeFacebookPostUrl(post.url);
if (!cleaned || cleaned === post.url) {
return null;
}
return { id: post.id, cleaned };
})
.filter(Boolean);
if (!candidates.length) {
return false;
}
let changed = false;
for (const candidate of candidates) {
try {
const response = await apiFetch(`${API_URL}/posts/${candidate.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: candidate.cleaned })
});
if (!response.ok) {
console.warn(`Konnte URL für Beitrag ${candidate.id} nicht normalisieren.`);
continue;
}
const updatedPost = await response.json();
posts = posts.map((item) => (item.id === candidate.id ? updatedPost : item));
changed = true;
} catch (error) {
console.warn(`Fehler beim Normalisieren der URL für Beitrag ${candidate.id}:`, error);
}
}
return changed;
}
function doesPostMatchFocus(post) {
if (!post) {
return false;
}
if (focusPostIdParam && String(post.id) === focusPostIdParam) {
return true;
}
if (focusNormalizedUrl && post.url) {
const candidateNormalized = normalizeFacebookPostUrl(post.url) || post.url;
return candidateNormalized === focusNormalizedUrl;
}
return false;
}
function resolveFocusTargetInfo(items) {
if (!Array.isArray(items) || (!focusPostIdParam && !focusNormalizedUrl)) {
return { index: -1, post: null };
}
const index = items.findIndex(({ post }) => doesPostMatchFocus(post));
return {
index,
post: index !== -1 && items[index] ? items[index].post : null
};
}
function clearFocusParamsFromUrl() {
try {
const url = new URL(window.location.href);
let changed = false;
if (url.searchParams.has('postId')) {
url.searchParams.delete('postId');
changed = true;
}
if (url.searchParams.has('postUrl')) {
url.searchParams.delete('postUrl');
changed = true;
}
if (changed) {
const newQuery = url.searchParams.toString();
const newUrl = `${url.pathname}${newQuery ? `?${newQuery}` : ''}${url.hash}`;
window.history.replaceState({}, document.title, newUrl);
}
} catch (error) {
console.warn('Konnte Fokus-Parameter nicht aus URL entfernen:', error);
}
}
function highlightPostCard(post) {
if (!post || focusHandled) {
return;
}
const card = document.getElementById(`post-${post.id}`);
if (!card) {
return;
}
card.classList.add('post-card--highlight');
const hadTabIndex = card.hasAttribute('tabindex');
if (!hadTabIndex) {
card.setAttribute('tabindex', '-1');
card.dataset.fbTrackerTempTabindex = '1';
}
requestAnimationFrame(() => {
try {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
} catch (error) {
console.warn('Konnte Karte nicht scrollen:', error);
}
try {
card.focus({ preventScroll: true });
} catch (error) {
// ignore focus errors
}
});
window.setTimeout(() => {
card.classList.remove('post-card--highlight');
if (card.dataset.fbTrackerTempTabindex === '1') {
card.removeAttribute('tabindex');
delete card.dataset.fbTrackerTempTabindex;
}
}, 4000);
focusPostIdParam = null;
focusPostUrlParam = null;
focusNormalizedUrl = '';
focusHandled = true;
focusTabAdjusted = null;
clearFocusParamsFromUrl();
}
// Render posts
function renderPosts() {
hideLoading();
hideError();
const container = document.getElementById('postsContainer');
if (!container) {
return;
}
closeActiveDeadlinePicker();
updateTabButtons();
cleanupLoadMoreObserver();
const postItems = posts.map((post) => ({
post,
status: computePostStatus(post)
}));
const sortedItems = [...postItems].sort(comparePostItems);
const focusCandidateEntry = (!focusHandled && (focusPostIdParam || focusNormalizedUrl))
? sortedItems.find((item) => doesPostMatchFocus(item.post))
: null;
let filteredItems = sortedItems;
if (currentTab === 'pending') {
filteredItems = sortedItems.filter((item) => !item.status.isExpired && item.status.canCurrentProfileCheck && !item.status.isComplete);
} else if (currentTab === 'expired') {
filteredItems = sortedItems.filter((item) => item.status.isExpired || item.status.isComplete);
} else if (currentTab === 'all') {
filteredItems = sortedItems.filter((item) => !item.status.isExpired && !item.status.isComplete);
}
const tabTotalCount = filteredItems.length;
const searchInput = document.getElementById('searchInput');
const searchValue = searchInput && typeof searchInput.value === 'string' ? searchInput.value.trim() : '';
const searchActive = Boolean(searchValue);
if (searchActive) {
const searchTerm = searchValue.toLowerCase();
filteredItems = filteredItems.filter((item) => {
const post = item.post;
return (
(post.title && post.title.toLowerCase().includes(searchTerm)) ||
(post.url && post.url.toLowerCase().includes(searchTerm)) ||
(post.created_by_name && post.created_by_name.toLowerCase().includes(searchTerm)) ||
(post.id && post.id.toLowerCase().includes(searchTerm))
);
});
}
if (!focusHandled && focusCandidateEntry && !searchActive) {
const candidateVisibleInCurrentTab = filteredItems.some(({ post }) => doesPostMatchFocus(post));
if (!candidateVisibleInCurrentTab) {
let desiredTab = 'all';
if (focusCandidateEntry.status.isExpired || focusCandidateEntry.status.isComplete) {
desiredTab = 'expired';
} else if (focusCandidateEntry.status.canCurrentProfileCheck && !focusCandidateEntry.status.isExpired && !focusCandidateEntry.status.isComplete) {
desiredTab = 'pending';
}
if (currentTab !== desiredTab && focusTabAdjusted !== desiredTab) {
focusTabAdjusted = desiredTab;
setTab(desiredTab);
return;
}
if (desiredTab !== 'all' && currentTab !== 'all' && focusTabAdjusted !== 'all') {
focusTabAdjusted = 'all';
setTab('all');
return;
}
}
}
const focusTargetInfo = resolveFocusTargetInfo(filteredItems);
if (focusTargetInfo.index !== -1) {
const requiredVisible = focusTargetInfo.index + 1;
if (requiredVisible > getVisibleCount(currentTab)) {
setVisibleCount(currentTab, requiredVisible);
}
}
updateFilteredCount(currentTab, filteredItems.length);
const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab));
const visibleItems = filteredItems.slice(0, visibleCount);
const summaryHtml = buildPostsSummary({
tab: currentTab,
visibleCount,
filteredCount: filteredItems.length,
tabTotalCount,
totalCountAll: posts.length,
searchActive
});
if (filteredItems.length === 0) {
let emptyMessage = 'Noch keine Beiträge erfasst.';
let emptyIcon = '🎉';
if (currentTab === 'pending') {
emptyMessage = 'Keine offenen Beiträge!';
} else if (currentTab === 'expired') {
emptyMessage = 'Keine abgelaufenen oder abgeschlossenen Beiträge.';
}
if (searchActive) {
emptyMessage = 'Keine Beiträge gefunden.';
emptyIcon = '🔍';
}
container.innerHTML = `${summaryHtml}
<div class="empty-state">
<div class="empty-state-icon">${emptyIcon}</div>
<div class="empty-state-text">
${emptyMessage}
</div>
</div>
`;
return;
}
container.innerHTML = `${summaryHtml}${visibleItems
.map(({ post, status }, index) => createPostCard(post, status, {
index,
globalIndex: index + 1,
totalFiltered: filteredItems.length,
totalOverall: posts.length,
tabTotalCount,
searchActive
}))
.join('')}`;
visibleItems.forEach(({ post, status }) => attachPostEventHandlers(post, status));
if (visibleCount < filteredItems.length) {
const loadMoreContainer = document.createElement('div');
loadMoreContainer.className = 'posts-load-more';
const loadMoreButton = document.createElement('button');
loadMoreButton.type = 'button';
loadMoreButton.className = 'btn btn-secondary posts-load-more__btn';
loadMoreButton.textContent = 'Weitere Beiträge laden';
loadMoreButton.addEventListener('click', () => {
loadMoreButton.disabled = true;
loadMoreButton.textContent = 'Lade...';
loadMorePosts(currentTab, { triggeredByScroll: false });
});
loadMoreContainer.appendChild(loadMoreButton);
container.appendChild(loadMoreContainer);
observeLoadMoreElement(loadMoreContainer, currentTab);
}
if (!focusHandled && focusTargetInfo.index !== -1 && focusTargetInfo.post) {
requestAnimationFrame(() => highlightPostCard(focusTargetInfo.post));
}
}
function attachPostEventHandlers(post, status) {
const card = document.getElementById(`post-${post.id}`);
if (!card) {
return;
}
const openBtn = card.querySelector('.btn-open');
if (openBtn) {
openBtn.addEventListener('click', () => openPost(post.id));
}
const deleteBtn = card.querySelector('.btn-delete');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => deletePost(post.id));
}
const screenshotEl = card.querySelector('.post-screenshot');
if (screenshotEl && screenshotEl.dataset.screenshot) {
const url = screenshotEl.dataset.screenshot;
const openHandler = () => openScreenshotModal(url);
screenshotEl.addEventListener('click', openHandler);
screenshotEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openScreenshotModal(url);
}
});
}
const toggleButtons = card.querySelectorAll('.profile-line__toggle');
toggleButtons.forEach((button) => {
button.addEventListener('click', () => {
const profileNumber = parseInt(button.dataset.profile, 10);
const currentStatus = button.dataset.status || 'pending';
toggleProfileStatus(post.id, profileNumber, currentStatus);
});
});
const editPostBtn = card.querySelector('.btn-edit-post');
if (editPostBtn) {
editPostBtn.addEventListener('click', () => {
openManualPostModal({ mode: 'edit', post });
});
}
const deadlineButton = card.querySelector('.post-deadline__calendar');
if (deadlineButton) {
deadlineButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
openNativeDeadlinePicker(post, deadlineButton);
});
}
const clearDeadlineButton = card.querySelector('.post-deadline__clear');
if (clearDeadlineButton) {
clearDeadlineButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
clearDeadline(post.id);
});
}
const successCheckbox = card.querySelector('.success-checkbox-input');
if (successCheckbox) {
successCheckbox.addEventListener('change', async () => {
await toggleSuccessStatus(post.id, successCheckbox.checked);
});
}
const targetSelect = card.querySelector('.post-target__select');
if (targetSelect) {
targetSelect.dataset.originalValue = String(status.targetCount);
targetSelect.addEventListener('change', () => {
const value = parseInt(targetSelect.value, 10);
if (targetSelect.dataset.originalValue && String(value) === targetSelect.dataset.originalValue) {
targetSelect.blur();
return;
}
updateTargetInline(post.id, value, targetSelect);
targetSelect.blur();
});
}
}
function openScreenshotModal(url) {
if (!screenshotModal || !url) {
return;
}
screenshotModalLastFocus = document.activeElement;
screenshotModalImage.src = url;
resetScreenshotZoom();
screenshotModalPreviousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
screenshotModal.removeAttribute('hidden');
screenshotModal.classList.add('open');
if (screenshotModalClose) {
screenshotModalClose.focus();
}
if (screenshotModalImage.complete) {
applyScreenshotModalSize();
} else {
const handleLoad = () => {
applyScreenshotModalSize();
};
screenshotModalImage.addEventListener('load', handleLoad, { once: true });
}
}
function closeScreenshotModal() {
if (!screenshotModal) {
return;
}
if (!screenshotModal.classList.contains('open')) {
return;
}
resetScreenshotZoom();
screenshotModal.classList.remove('open');
screenshotModal.setAttribute('hidden', '');
screenshotModalImage.src = '';
document.body.style.overflow = screenshotModalPreviousOverflow;
if (screenshotModalLastFocus && typeof screenshotModalLastFocus.focus === 'function') {
screenshotModalLastFocus.focus();
}
}
function resetScreenshotZoom() {
screenshotModalZoomed = false;
if (screenshotModalContent) {
screenshotModalContent.classList.remove('zoomed');
screenshotModalContent.style.width = '';
screenshotModalContent.style.height = '';
screenshotModalContent.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}
if (screenshotModalImage) {
screenshotModalImage.classList.remove('zoomed');
}
applyScreenshotModalSize();
}
function toggleScreenshotZoom() {
if (!screenshotModalContent || !screenshotModalImage) {
return;
}
screenshotModalZoomed = !screenshotModalZoomed;
screenshotModalContent.classList.toggle('zoomed', screenshotModalZoomed);
screenshotModalImage.classList.toggle('zoomed', screenshotModalZoomed);
if (screenshotModalZoomed) {
screenshotModalContent.style.width = 'min(95vw, 1300px)';
screenshotModalContent.style.height = '92vh';
screenshotModalContent.scrollTo({ top: 0, left: 0, behavior: 'auto' });
} else {
screenshotModalContent.style.width = '';
screenshotModalContent.style.height = '';
applyScreenshotModalSize();
}
}
// Create post card HTML
function createPostCard(post, status, meta = {}) {
const createdDate = formatDateTime(post.created_at) || '—';
const lastChangeDate = formatDateTime(post.last_change || post.created_at) || '—';
const baseScreenshotPath = post.screenshot_path
? (post.screenshot_path.startsWith('http')
? post.screenshot_path
: `${API_URL.replace(/\/api$/, '')}${post.screenshot_path.startsWith('/') ? '' : '/'}${post.screenshot_path}`)
: `${API_URL}/posts/${post.id}/screenshot`;
const screenshotVersion = post.last_change || post.updated_at || post.created_at || '';
const versionedScreenshotPath = screenshotVersion
? `${baseScreenshotPath}${baseScreenshotPath.includes('?') ? '&' : '?'}v=${encodeURIComponent(screenshotVersion)}`
: baseScreenshotPath;
const resolvedScreenshotPath = versionedScreenshotPath;
const screenshotHtml = `
<div class="post-screenshot" data-screenshot="${escapeHtml(versionedScreenshotPath)}" role="button" tabindex="0" aria-label="Screenshot anzeigen">
<img src="${escapeHtml(versionedScreenshotPath)}" alt="Screenshot zum Beitrag" loading="lazy" />
</div>
`;
const displayIndex = typeof meta.globalIndex === 'number' ? meta.globalIndex : typeof meta.index === 'number' ? meta.index + 1 : null;
const totalFiltered = typeof meta.totalFiltered === 'number' ? meta.totalFiltered : posts.length;
const totalOverall = typeof meta.totalOverall === 'number' ? meta.totalOverall : posts.length;
const tabTotalCount = typeof meta.tabTotalCount === 'number' ? meta.tabTotalCount : posts.length;
const searchActive = !!meta.searchActive;
const counterBadge = displayIndex !== null
? `
<div class="post-counter" aria-hidden="true">
<span class="post-counter__value">${String(displayIndex).padStart(2, '0')}</span>
</div>
`
: '';
const profileRowsHtml = status.profileStatuses.map((profileStatus) => {
const classes = ['profile-line', `profile-line--${profileStatus.status}`];
const isCurrentProfile = parseInt(profileStatus.profile_number, 10) === status.profileNumber;
if (isCurrentProfile) {
classes.push('profile-line--current');
}
let label = 'Wartet';
if (profileStatus.status === 'done') {
const doneDate = formatDateTime(profileStatus.checked_at);
label = doneDate ? `Erledigt (${doneDate})` : 'Erledigt';
} else if (profileStatus.status === 'available') {
label = 'Bereit';
}
const toggleLabel = profileStatus.status === 'done'
? 'Als offen markieren'
: 'Als erledigt markieren';
// Disable toggle button if post is expired
const toggleDisabled = status.isExpired ? 'disabled' : '';
const badgeHtml = isCurrentProfile ? '<span class="profile-line__badge">Dein Profil</span>' : '';
return `
<div class="${classes.join(' ')}">
<span class="profile-line__name">${escapeHtml(profileStatus.profile_name)}${badgeHtml}</span>
<span class="profile-line__status">${escapeHtml(label)}</span>
<div class="profile-line__actions">
<button type="button" class="profile-line__toggle" data-post-id="${post.id}" data-profile="${profileStatus.profile_number}" data-status="${profileStatus.status}" ${toggleDisabled}>
${escapeHtml(toggleLabel)}
</button>
</div>
</div>
`;
}).join('');
const infoMessages = [];
if (status.isExpired) {
infoMessages.push('Deadline ist abgelaufen.');
}
if (!status.isCurrentProfileRequired) {
infoMessages.push('Dieses Profil muss den Beitrag nicht bestätigen.');
} else if (status.isCurrentProfileDone) {
infoMessages.push('Für dein Profil erledigt.');
} else if (status.waitingForNames.length) {
infoMessages.push(`Wartet auf: ${status.waitingForNames.join(', ')}`);
}
const infoHtml = infoMessages.length
? `
<div class="post-hints">
${infoMessages.map((message) => `
<div class="post-hint${message.includes('erledigt') ? ' post-hint--success' : ''}">
${escapeHtml(message)}
</div>
`).join('')}
</div>
`
: '';
const directLinkHtml = post.url
? `
<div class="post-link">
<span class="post-link__label">Direktlink:</span>
<a href="${escapeHtml(post.url)}" target="_blank" rel="noopener noreferrer" class="post-link__anchor">
${escapeHtml(formatUrlForDisplay(post.url))}
</a>
</div>
`
: '';
const openButtonHtml = (status.canCurrentProfileCheck && !status.isExpired)
? `
<button class="btn btn-success btn-open">Beitrag öffnen & abhaken</button>
`
: '';
const bodyClasses = ['post-body'];
if (resolvedScreenshotPath) {
bodyClasses.push('post-body--with-screenshot');
}
let creatorName = typeof post.created_by_name === 'string' && post.created_by_name.trim()
? post.created_by_name.trim()
: null;
// Remove ", Story ansehen" suffix if present
if (creatorName && creatorName.endsWith(', Story ansehen')) {
creatorName = creatorName.slice(0, -16).trim();
}
const creatorDisplay = creatorName || 'Unbekannt';
const titleText = (post.title && post.title.trim()) ? post.title.trim() : creatorDisplay;
const deadlineText = formatDeadline(post.deadline_at);
const hasDeadline = Boolean(post.deadline_at);
const isOverdue = hasDeadline && (new Date(post.deadline_at).getTime() < Date.now());
const deadlineClasses = ['post-deadline'];
let deadlineStyle = '';
if (hasDeadline) {
deadlineClasses.push('has-deadline');
// Calculate color based on time until deadline (smooth gradient)
const now = Date.now();
const deadlineTime = new Date(post.deadline_at).getTime();
const hoursUntilDeadline = (deadlineTime - now) / (1000 * 60 * 60);
let color;
if (hoursUntilDeadline < 0) {
// Overdue - dark red
color = '#dc2626';
} else {
// Smooth gradient from red (0h) to default gray (168h/7 days)
const maxHours = 168; // 7 days
const ratio = Math.min(hoursUntilDeadline / maxHours, 1);
// Color stops: red -> default gray (#4b5563)
// Red: rgb(220, 38, 38)
// Gray: rgb(75, 85, 99)
const r = Math.round(220 - (220 - 75) * ratio);
const g = Math.round(38 + (85 - 38) * ratio);
const b = Math.round(38 + (99 - 38) * ratio);
color = `rgb(${r}, ${g}, ${b})`;
}
deadlineStyle = `style="color: ${color};"`;
}
if (isOverdue) {
deadlineClasses.push('overdue');
}
return `
<div class="post-card ${status.isComplete ? 'complete' : ''}" id="post-${post.id}">
<div class="post-header">
${counterBadge}
<div class="post-title-with-checkbox">
<div class="post-title">${escapeHtml(titleText)}</div>
<label class="success-checkbox success-checkbox--header">
<input type="checkbox" class="success-checkbox-input" data-post-id="${post.id}" ${post.is_successful ? 'checked' : ''}>
<span>Erfolgreich</span>
</label>
</div>
<div class="post-header-right">
<div class="post-target">
<span>Benötigte Profile:</span>
<select class="control-select post-target__select" data-post-id="${post.id}">
${Array.from({ length: MAX_PROFILES }, (_, index) => index + 1)
.map((value) => `
<option value="${value}" ${value === status.targetCount ? 'selected' : ''}>${value}</option>
`).join('')}
</select>
</div>
<div class="post-status ${status.isComplete ? 'complete' : ''}">
${status.checkedCount}/${status.targetCount} ${status.isComplete ? '✓' : ''}
</div>
</div>
</div>
<div class="${bodyClasses.join(' ')}">
<div class="post-screenshot-wrapper">
${screenshotHtml}
</div>
<div class="post-content">
<div class="post-meta">
<div class="post-meta__line post-meta__line--count">
<span class="post-meta__label">Beitrag:</span>
<span class="post-meta__value">#${displayIndex !== null ? displayIndex : '-'}</span>
<span class="post-meta__stats">
(${searchActive ? `${totalFiltered} Treffer von ${tabTotalCount}` : `${tabTotalCount} im Tab`} · ${totalOverall} gesamt)
</span>
</div>
<div class="post-info">
<div>Erstellt: ${escapeHtml(createdDate)}</div>
<div>Letzte Änderung: ${escapeHtml(lastChangeDate)}</div>
</div>
<div class="post-creator">Erstellt von: ${escapeHtml(creatorDisplay)}</div>
</div>
${directLinkHtml}
<div class="post-deadline-row" data-post-id="${post.id}">
<span class="${deadlineClasses.join(' ')}" ${deadlineStyle}>Deadline: ${escapeHtml(deadlineText)}</span>
<button type="button" class="post-deadline__calendar" aria-label="Deadline bearbeiten" data-post-id="${post.id}">
📅
</button>
${hasDeadline ? `
<button type="button" class="post-deadline__clear" aria-label="Deadline entfernen" data-post-id="${post.id}">
</button>
` : ''}
</div>
<div class="post-profiles">
${profileRowsHtml}
</div>
${infoHtml}
<div class="post-actions">
${openButtonHtml}
<button type="button" class="btn btn-secondary btn-edit-post">Bearbeiten</button>
${post.url ? `
<a href="${escapeHtml(post.url)}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary btn-direct-link">
Direkt öffnen
</a>
` : ''}
<button class="btn btn-danger btn-delete">Löschen</button>
</div>
</div>
</div>
</div>
`;
}
// Open post and auto-check
async function openPost(postId) {
const post = posts.find((item) => item.id === postId);
if (!post) {
alert('Beitrag konnte nicht gefunden werden.');
return;
}
if (!post.url) {
alert('Für diesen Beitrag ist kein Direktlink vorhanden.');
return;
}
const status = computePostStatus(post);
if (!status.isCurrentProfileRequired) {
alert('Dieses Profil muss den Beitrag nicht bestätigen.');
return;
}
if (status.isCurrentProfileDone) {
window.open(post.url, '_blank');
return;
}
if (!status.canCurrentProfileCheck) {
if (status.waitingForNames.length) {
alert(`Wartet auf: ${status.waitingForNames.join(', ')}`);
} else {
alert('Der Beitrag kann aktuell nicht abgehakt werden.');
}
return;
}
try {
const response = await apiFetch(`${API_URL}/check-by-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: post.url,
profile_number: currentProfile
})
});
if (!response.ok) {
if (response.status === 409) {
const data = await response.json().catch(() => null);
if (data && data.error) {
alert(data.error);
return;
}
}
throw new Error('Failed to check post');
}
window.open(post.url, '_blank');
await fetchPosts({ showLoader: false });
} catch (error) {
alert('Fehler beim Abhaken des Beitrags');
console.error('Error checking post:', error);
}
}
async function toggleSuccessStatus(postId, isSuccessful) {
try {
const response = await apiFetch(`${API_URL}/posts/${postId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_successful: isSuccessful })
});
if (!response.ok) {
alert('Status konnte nicht geändert werden.');
return;
}
const updatedPost = await response.json();
posts = posts.map((item) => (item.id === postId ? updatedPost : item));
renderPosts();
} catch (error) {
console.error('Error updating success status:', error);
alert('Status konnte nicht geändert werden.');
}
}
async function toggleProfileStatus(postId, profileNumber, currentStatus) {
if (!profileNumber) {
return;
}
const desiredStatus = currentStatus === 'done' ? 'pending' : 'done';
try {
const response = await apiFetch(`${API_URL}/posts/${postId}/profile-status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profile_number: profileNumber,
status: desiredStatus
})
});
if (!response.ok) {
const data = await response.json().catch(() => null);
const message = data && data.error ? data.error : 'Status konnte nicht geändert werden.';
alert(message);
return;
}
const updatedPost = await response.json();
posts = posts.map((item) => (item.id === postId ? updatedPost : item));
renderPosts();
} catch (error) {
console.error('Error updating profile status:', error);
alert('Profilstatus konnte nicht geändert werden.');
}
}
function populateManualPostForm(post) {
if (!manualPostForm || !post) {
return;
}
if (manualPostUrlInput) {
const normalizedUrl = typeof post.url === 'string' ? normalizeFacebookPostUrl(post.url) : null;
manualPostUrlInput.value = normalizedUrl || post.url || '';
manualPostUrlInput.disabled = true;
manualPostUrlInput.readOnly = true;
}
if (manualPostTitleInput) {
manualPostTitleInput.value = post.title || '';
}
if (manualPostTargetSelect) {
const targetValue = parseInt(post.target_count, 10);
manualPostTargetSelect.value = Number.isNaN(targetValue) ? '1' : String(targetValue);
}
if (manualPostCreatorInput) {
manualPostCreatorInput.value = post.created_by_name || '';
}
if (manualPostDeadlineInput) {
const existingValue = toDateTimeLocalValue(post.deadline_at);
manualPostDeadlineInput.value = existingValue || getDefaultDeadlineInputValue();
}
clearManualPostMessage();
}
function resetManualPostForm({ keepMessages = false } = {}) {
if (!manualPostForm) {
return;
}
manualPostMode = 'create';
manualPostEditingId = null;
manualPostForm.reset();
if (manualPostTargetSelect) {
manualPostTargetSelect.value = '1';
}
if (manualPostUrlInput) {
manualPostUrlInput.disabled = false;
manualPostUrlInput.readOnly = false;
manualPostUrlInput.value = '';
}
if (manualPostTitleInput) {
manualPostTitleInput.value = '';
}
if (manualPostCreatorInput) {
manualPostCreatorInput.value = '';
}
if (manualPostDeadlineInput) {
manualPostDeadlineInput.value = getDefaultDeadlineInputValue();
}
if (manualPostModalTitle) {
manualPostModalTitle.textContent = 'Beitrag hinzufügen';
}
if (manualPostSubmitButton) {
manualPostSubmitButton.textContent = 'Speichern';
}
if (!keepMessages) {
clearManualPostMessage();
}
}
async function saveDeadline(postId, deadlineIso) {
try {
const response = await apiFetch(`${API_URL}/posts/${postId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deadline_at: deadlineIso })
});
if (!response.ok) {
const data = await response.json().catch(() => null);
const message = data && data.error ? data.error : 'Deadline konnte nicht gespeichert werden.';
alert(message);
return false;
}
const updatedPost = await response.json();
posts = posts.map((item) => (item.id === postId ? updatedPost : item));
renderPosts();
return true;
} catch (error) {
console.error('Error updating deadline:', error);
alert('Deadline konnte nicht gespeichert werden.');
return false;
}
}
async function updateTargetInline(postId, value, selectElement) {
if (!selectElement) {
return;
}
if (Number.isNaN(value) || value < 1 || value > MAX_PROFILES) {
renderPosts();
return;
}
try {
const response = await apiFetch(`${API_URL}/posts/${postId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_count: value })
});
if (!response.ok) {
const data = await response.json().catch(() => null);
const message = data && data.error ? data.error : 'Anzahl konnte nicht gespeichert werden.';
alert(message);
if (selectElement.dataset.originalValue) {
selectElement.value = selectElement.dataset.originalValue;
}
renderPosts();
return;
}
const updatedPost = await response.json();
posts = posts.map((item) => (item.id === postId ? updatedPost : item));
renderPosts();
} catch (error) {
console.error('Error updating target count:', error);
alert('Anzahl konnte nicht gespeichert werden.');
if (selectElement.dataset.originalValue) {
selectElement.value = selectElement.dataset.originalValue;
}
renderPosts();
}
}
async function handleManualPostSubmit(event) {
event.preventDefault();
if (!manualPostForm) {
return;
}
clearManualPostMessage();
const urlValue = manualPostUrlInput ? manualPostUrlInput.value.trim() : '';
if (!urlValue) {
displayManualPostMessage('Bitte gib einen Direktlink an.', 'error');
if (manualPostUrlInput) {
manualPostUrlInput.focus();
}
return;
}
const targetValue = manualPostTargetSelect ? manualPostTargetSelect.value : '1';
const parsedTarget = parseInt(targetValue, 10);
if (Number.isNaN(parsedTarget) || parsedTarget < 1 || parsedTarget > MAX_PROFILES) {
displayManualPostMessage('Die Anzahl der benötigten Profile muss zwischen 1 und 5 liegen.', 'error');
return;
}
const creatorValue = manualPostCreatorInput ? manualPostCreatorInput.value.trim() : '';
const deadlineValue = manualPostDeadlineInput ? manualPostDeadlineInput.value : '';
const titleValue = manualPostTitleInput ? manualPostTitleInput.value.trim() : '';
const cleanedUrl = normalizeFacebookPostUrl(urlValue);
if (!cleanedUrl) {
displayManualPostMessage('Bitte gib einen gültigen Facebook-Link an.', 'error');
if (manualPostUrlInput) {
manualPostUrlInput.focus();
manualPostUrlInput.select?.();
}
return;
}
const payload = {
url: cleanedUrl,
target_count: parsedTarget
};
if (titleValue) {
payload.title = titleValue;
}
if (creatorValue) {
payload.created_by_name = creatorValue;
}
const normalizedDeadline = normalizeDeadlineInput(deadlineValue);
if (normalizedDeadline) {
payload.deadline_at = normalizedDeadline;
}
const submitButtons = manualPostForm.querySelectorAll('button, input[type="submit"]');
submitButtons.forEach((btn) => {
btn.disabled = true;
});
try {
if (manualPostMode === 'edit' && manualPostEditingId) {
const updatePayload = {
target_count: parsedTarget,
title: titleValue || ''
};
if (creatorValue || creatorValue === '') {
updatePayload.created_by_name = creatorValue || null;
}
updatePayload.deadline_at = normalizedDeadline;
const response = await apiFetch(`${API_URL}/posts/${manualPostEditingId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatePayload)
});
if (!response.ok) {
const data = await response.json().catch(() => null);
const message = data && data.error ? data.error : 'Beitrag konnte nicht aktualisiert werden.';
displayManualPostMessage(message, 'error');
return;
}
const updatedPost = await response.json();
posts = posts.map((item) => (item.id === manualPostEditingId ? updatedPost : item));
renderPosts();
closeManualPostModal();
} else {
const response = await apiFetch(`${API_URL}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json().catch(() => null);
if (response.status === 409 && data && data.error) {
displayManualPostMessage(data.error, 'error');
} else {
const message = data && data.error ? data.error : 'Beitrag konnte nicht erstellt werden.';
displayManualPostMessage(message, 'error');
}
return;
}
const createdPost = await response.json();
posts = [createdPost, ...posts.filter((item) => item.id !== createdPost.id)];
renderPosts();
displayManualPostMessage('Beitrag wurde erstellt.', 'success');
resetManualPostForm({ keepMessages: true });
if (manualPostUrlInput) {
manualPostUrlInput.focus();
}
}
} catch (error) {
console.error('Error creating manual post:', error);
displayManualPostMessage('Beitrag konnte nicht erstellt werden.', 'error');
} finally {
submitButtons.forEach((btn) => {
btn.disabled = false;
});
}
}
// Delete post
async function deletePost(postId) {
if (!confirm('Beitrag wirklich löschen?')) {
return;
}
try {
const response = await apiFetch(`${API_URL}/posts/${postId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete post');
}
await fetchPosts({ showLoader: false });
} catch (error) {
alert('Fehler beim Löschen des Beitrags');
console.error('Error deleting post:', error);
}
}
// Utility functions
function showLoading() {
document.getElementById('loading').style.display = 'block';
document.getElementById('postsContainer').style.display = 'none';
}
function hideLoading() {
document.getElementById('loading').style.display = 'none';
document.getElementById('postsContainer').style.display = 'block';
}
function showError(message) {
const errorEl = document.getElementById('error');
errorEl.textContent = message;
errorEl.style.display = 'block';
}
function hideError() {
document.getElementById('error').style.display = 'none';
}
function escapeHtml(unsafe) {
if (unsafe === null || unsafe === undefined) {
unsafe = '';
}
if (typeof unsafe !== 'string') {
unsafe = String(unsafe);
}
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Auto-check on page load if URL parameter is present
function checkAutoCheck() {
const urlParams = new URLSearchParams(window.location.search);
const autoCheckUrl = urlParams.get('check');
if (autoCheckUrl) {
// Try to check this URL automatically
apiFetch(`${API_URL}/check-by-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: decodeURIComponent(autoCheckUrl),
profile_number: currentProfile
})
}).then(() => {
// Remove nur den check-Parameter aus der URL
try {
const url = new URL(window.location.href);
url.searchParams.delete('check');
const paramsString = url.searchParams.toString();
const newUrl = paramsString ? `${url.pathname}?${paramsString}${url.hash}` : `${url.pathname}${url.hash}`;
window.history.replaceState({}, document.title, newUrl);
} catch (error) {
console.warn('Konnte check-Parameter nicht entfernen:', error);
}
fetchPosts({ showLoader: false });
}).catch(console.error);
}
}
if (screenshotModalClose) {
screenshotModalClose.addEventListener('click', closeScreenshotModal);
}
if (screenshotModalBackdrop) {
screenshotModalBackdrop.addEventListener('click', closeScreenshotModal);
}
if (screenshotModalImage) {
screenshotModalImage.addEventListener('click', (event) => {
event.stopPropagation();
toggleScreenshotZoom();
});
screenshotModalImage.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleScreenshotZoom();
}
});
screenshotModalImage.setAttribute('tabindex', '0');
screenshotModalImage.setAttribute('role', 'button');
screenshotModalImage.setAttribute('aria-label', 'Screenshot vergrößern oder verkleinern');
}
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') {
return;
}
if (manualPostModal && manualPostModal.classList.contains('open')) {
event.preventDefault();
closeManualPostModal();
return;
}
if (bookmarkPanelVisible) {
event.preventDefault();
toggleBookmarkPanel(false);
return;
}
if (screenshotModal && screenshotModal.classList.contains('open')) {
if (screenshotModalZoomed) {
resetScreenshotZoom();
return;
}
closeScreenshotModal();
}
});
window.addEventListener('resize', () => {
if (screenshotModal && screenshotModal.classList.contains('open') && !screenshotModalZoomed) {
applyScreenshotModalSize();
}
});
// Initialize
initializeBookmarks();
loadAutoRefreshSettings();
initializeFocusParams();
initializeTabFromUrl();
loadSortMode();
resetManualPostForm();
loadProfile();
startProfilePolling();
fetchPosts();
checkAutoCheck();
startUpdatesStream();
applyAutoRefreshSettings();