aktueller stand

This commit is contained in:
2025-12-29 19:45:08 +01:00
parent fde5ab91c8
commit 677eac2632
6 changed files with 1888 additions and 272 deletions

View File

@@ -300,6 +300,14 @@ const includeExpiredToggle = document.getElementById('includeExpiredToggle');
const mergeControls = document.getElementById('mergeControls');
const mergeModeToggle = document.getElementById('mergeModeToggle');
const mergeSubmitBtn = document.getElementById('mergeSubmitBtn');
const pendingBulkControls = document.getElementById('pendingBulkControls');
const pendingBulkCountSelect = document.getElementById('pendingBulkCountSelect');
const pendingBulkOpenBtn = document.getElementById('pendingBulkOpenBtn');
const pendingAutoOpenToggle = document.getElementById('pendingAutoOpenToggle');
const pendingAutoOpenOverlay = document.getElementById('pendingAutoOpenOverlay');
const pendingAutoOpenOverlayPanel = document.getElementById('pendingAutoOpenOverlayPanel');
const pendingAutoOpenCountdown = document.getElementById('pendingAutoOpenCountdown');
const pendingBulkStatus = document.getElementById('pendingBulkStatus');
const REFRESH_SETTINGS_KEY = 'trackerRefreshSettings';
const SORT_SETTINGS_KEY = 'trackerSortSettings';
@@ -313,6 +321,12 @@ const BOOKMARK_WINDOW_DAYS = 28;
const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen'];
const BOOKMARK_PREFS_KEY = 'trackerBookmarkPreferences';
const INCLUDE_EXPIRED_STORAGE_KEY = 'trackerIncludeExpired';
const PENDING_BULK_COUNT_STORAGE_KEY = 'trackerPendingBulkCount';
const PENDING_AUTO_OPEN_STORAGE_KEY = 'trackerPendingAutoOpen';
const DEFAULT_PENDING_BULK_COUNT = 5;
const PENDING_AUTO_OPEN_DELAY_MS = 1500;
const PENDING_OPEN_COOLDOWN_STORAGE_KEY = 'trackerPendingOpenCooldown';
const PENDING_OPEN_COOLDOWN_MS = 40 * 60 * 1000;
function loadIncludeExpiredPreference() {
try {
@@ -337,6 +351,110 @@ function persistIncludeExpiredPreference(value) {
}
}
function getPendingOpenCooldownStorageKey(profileNumber = currentProfile) {
const safeProfile = profileNumber || currentProfile || 1;
return `${PENDING_OPEN_COOLDOWN_STORAGE_KEY}:${safeProfile}`;
}
function loadPendingOpenCooldownMap(profileNumber = currentProfile) {
const storageKey = getPendingOpenCooldownStorageKey(profileNumber);
try {
const raw = localStorage.getItem(storageKey);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
const now = Date.now();
const cleaned = {};
Object.entries(parsed).forEach(([id, timestamp]) => {
const value = Number(timestamp);
if (Number.isFinite(value) && now - value < PENDING_OPEN_COOLDOWN_MS) {
cleaned[id] = value;
}
});
if (Object.keys(cleaned).length !== Object.keys(parsed).length) {
localStorage.setItem(storageKey, JSON.stringify(cleaned));
}
return cleaned;
}
}
} catch (error) {
console.warn('Konnte Cooldown-Daten für offene Beiträge nicht laden:', error);
}
return {};
}
function persistPendingOpenCooldownMap(profileNumber, map) {
const storageKey = getPendingOpenCooldownStorageKey(profileNumber);
try {
localStorage.setItem(storageKey, JSON.stringify(map || {}));
} catch (error) {
console.warn('Konnte Cooldown-Daten für offene Beiträge nicht speichern:', error);
}
}
function isPendingOpenCooldownActive(postId) {
if (!postId) {
return false;
}
const timestamp = pendingOpenCooldownMap[postId];
if (!timestamp) {
return false;
}
const elapsed = Date.now() - timestamp;
if (elapsed < PENDING_OPEN_COOLDOWN_MS) {
return true;
}
delete pendingOpenCooldownMap[postId];
persistPendingOpenCooldownMap(currentProfile, pendingOpenCooldownMap);
return false;
}
function recordPendingOpen(postId) {
if (!postId) {
return;
}
pendingOpenCooldownMap[postId] = Date.now();
persistPendingOpenCooldownMap(currentProfile, pendingOpenCooldownMap);
}
function loadPendingBulkCount() {
try {
const stored = localStorage.getItem(PENDING_BULK_COUNT_STORAGE_KEY);
const value = parseInt(stored, 10);
if (!Number.isNaN(value) && [1, 5, 10, 15, 20].includes(value)) {
return value;
}
} catch (error) {
console.warn('Konnte Bulk-Anzahl für offene Beiträge nicht laden:', error);
}
return DEFAULT_PENDING_BULK_COUNT;
}
function persistPendingBulkCount(value) {
try {
localStorage.setItem(PENDING_BULK_COUNT_STORAGE_KEY, String(value));
} catch (error) {
console.warn('Konnte Bulk-Anzahl für offene Beiträge nicht speichern:', error);
}
}
function loadPendingAutoOpenEnabled() {
try {
return localStorage.getItem(PENDING_AUTO_OPEN_STORAGE_KEY) === '1';
} catch (error) {
console.warn('Konnte Auto-Öffnen-Status nicht laden:', error);
return false;
}
}
function persistPendingAutoOpenEnabled(enabled) {
try {
localStorage.setItem(PENDING_AUTO_OPEN_STORAGE_KEY, enabled ? '1' : '0');
} catch (error) {
console.warn('Konnte Auto-Öffnen-Status nicht speichern:', error);
}
}
function updateIncludeExpiredToggleUI() {
if (!includeExpiredToggle) {
return;
@@ -345,6 +463,13 @@ function updateIncludeExpiredToggleUI() {
}
includeExpiredPosts = loadIncludeExpiredPreference();
let pendingBulkCount = loadPendingBulkCount();
let pendingAutoOpenEnabled = loadPendingAutoOpenEnabled();
let pendingAutoOpenTriggered = false;
let pendingAutoOpenTimerId = null;
let pendingAutoOpenCountdownIntervalId = null;
let pendingProcessingBatch = false;
let pendingOpenCooldownMap = loadPendingOpenCooldownMap(currentProfile);
function updateIncludeExpiredToggleVisibility() {
if (!includeExpiredToggle) {
@@ -388,6 +513,26 @@ function updateMergeControlsUI() {
}
}
function updatePendingBulkControls(filteredCount = 0) {
if (!pendingBulkControls) {
return;
}
const isPendingTab = currentTab === 'pending';
pendingBulkControls.hidden = !isPendingTab;
pendingBulkControls.style.display = isPendingTab ? 'flex' : 'none';
if (pendingBulkOpenBtn) {
pendingBulkOpenBtn.disabled = !isPendingTab || pendingProcessingBatch || filteredCount === 0;
}
}
function setPendingBulkStatus(message = '', isError = false) {
if (!pendingBulkStatus) {
return;
}
pendingBulkStatus.textContent = message || '';
pendingBulkStatus.classList.toggle('bulk-status--error', !!isError);
}
function initializeFocusParams() {
try {
const params = new URLSearchParams(window.location.search);
@@ -1635,6 +1780,16 @@ function updateSortDirectionToggleUI() {
}
}
function getDefaultSortDirectionForMode(mode) {
if (mode === 'deadline') {
return 'asc';
}
if (mode === 'smart') {
return 'desc';
}
return null;
}
function normalizeRequiredProfiles(post) {
if (Array.isArray(post.required_profiles) && post.required_profiles.length) {
return post.required_profiles
@@ -1714,6 +1869,224 @@ function updateFilteredCount(tab, count) {
tabFilteredCounts[key] = Math.max(0, count || 0);
}
function getPostListState() {
const postItems = posts.map((post) => ({
post,
status: computePostStatus(post)
}));
const sortedItems = [...postItems].sort(comparePostItems);
let filteredItems = sortedItems;
if (currentTab === 'pending') {
filteredItems = sortedItems.filter((item) => !item.status.isExpired && item.status.canCurrentProfileCheck && !item.status.isComplete);
} else {
filteredItems = includeExpiredPosts
? sortedItems
: sortedItems.filter((item) => !item.status.isExpired && !item.status.isComplete);
}
const tabTotalCount = filteredItems.length;
const searchInput = document.getElementById('searchInput');
const searchValue = searchInput && typeof searchInput.value === 'string' ? searchInput.value.trim() : '';
const searchActive = Boolean(searchValue);
if (searchActive) {
const searchTerm = searchValue.toLowerCase();
filteredItems = filteredItems.filter((item) => {
const post = item.post;
return (
(post.title && post.title.toLowerCase().includes(searchTerm)) ||
(post.url && post.url.toLowerCase().includes(searchTerm)) ||
(post.created_by_name && post.created_by_name.toLowerCase().includes(searchTerm)) ||
(post.id && post.id.toLowerCase().includes(searchTerm))
);
});
}
return {
sortedItems,
filteredItems,
tabTotalCount,
searchActive,
searchValue
};
}
function clearPendingAutoOpenCountdown() {
if (pendingAutoOpenCountdownIntervalId) {
clearInterval(pendingAutoOpenCountdownIntervalId);
pendingAutoOpenCountdownIntervalId = null;
}
}
function updatePendingAutoOpenCountdownLabel(remainingMs) {
if (!pendingAutoOpenCountdown) {
return;
}
const safeMs = Math.max(0, remainingMs);
const seconds = safeMs / 1000;
const formatted = seconds >= 10 ? seconds.toFixed(0) : seconds.toFixed(1);
pendingAutoOpenCountdown.textContent = formatted;
}
function hidePendingAutoOpenOverlay() {
clearPendingAutoOpenCountdown();
if (pendingAutoOpenOverlay) {
pendingAutoOpenOverlay.classList.remove('visible');
pendingAutoOpenOverlay.hidden = true;
}
}
function showPendingAutoOpenOverlay(delayMs) {
if (!pendingAutoOpenOverlay) {
return;
}
const duration = Math.max(0, delayMs);
hidePendingAutoOpenOverlay();
pendingAutoOpenOverlay.hidden = false;
requestAnimationFrame(() => pendingAutoOpenOverlay.classList.add('visible'));
updatePendingAutoOpenCountdownLabel(duration);
const start = Date.now();
pendingAutoOpenCountdownIntervalId = setInterval(() => {
const remaining = Math.max(0, duration - (Date.now() - start));
updatePendingAutoOpenCountdownLabel(remaining);
if (remaining <= 0) {
clearPendingAutoOpenCountdown();
}
}, 100);
}
function cancelPendingAutoOpen(showMessage = false) {
if (pendingAutoOpenTimerId) {
clearTimeout(pendingAutoOpenTimerId);
pendingAutoOpenTimerId = null;
}
pendingAutoOpenTriggered = false;
hidePendingAutoOpenOverlay();
if (showMessage) {
setPendingBulkStatus('Automatisches Öffnen abgebrochen.', false);
}
}
function getPendingVisibleCandidates() {
if (currentTab !== 'pending') {
return { items: [], totalVisible: 0, cooldownBlocked: 0 };
}
const { filteredItems } = getPostListState();
const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab));
const visibleItems = filteredItems
.slice(0, visibleCount)
.filter(({ post }) => post && post.url);
const items = visibleItems.filter(({ post }) => !isPendingOpenCooldownActive(post.id));
const cooldownBlocked = Math.max(0, visibleItems.length - items.length);
return { items, totalVisible: visibleItems.length, cooldownBlocked };
}
function openPendingBatch({ auto = false } = {}) {
if (pendingProcessingBatch) {
return;
}
if (!auto) {
cancelPendingAutoOpen(false);
}
const { items: candidates, totalVisible, cooldownBlocked } = getPendingVisibleCandidates();
if (!candidates.length) {
if (!auto) {
if (totalVisible === 0) {
setPendingBulkStatus('Keine offenen Beiträge zum Öffnen.', true);
} else if (cooldownBlocked > 0) {
setPendingBulkStatus('Alle sichtbaren Beiträge sind noch im Cooldown (40 min).', true);
} else {
setPendingBulkStatus('Keine offenen Beiträge zum Öffnen.', true);
}
}
pendingAutoOpenTriggered = false;
return;
}
const count = pendingBulkCount || DEFAULT_PENDING_BULK_COUNT;
const selection = candidates.slice(0, count);
pendingProcessingBatch = true;
if (pendingBulkOpenBtn) {
pendingBulkOpenBtn.disabled = true;
}
if (!auto) {
setPendingBulkStatus('');
} else {
setPendingBulkStatus(`Öffne automatisch ${selection.length} Links...`, false);
}
selection.forEach(({ post }) => {
if (post && post.url) {
window.open(post.url, '_blank', 'noopener');
recordPendingOpen(post.id);
}
});
pendingProcessingBatch = false;
if (pendingBulkOpenBtn) {
pendingBulkOpenBtn.disabled = false;
}
if (auto) {
setPendingBulkStatus('');
pendingAutoOpenTriggered = false;
}
}
function maybeAutoOpenPending(reason = '', delayMs = PENDING_AUTO_OPEN_DELAY_MS) {
if (!isPostsViewActive()) {
hidePendingAutoOpenOverlay();
return;
}
if (currentTab !== 'pending') {
hidePendingAutoOpenOverlay();
return;
}
if (!pendingAutoOpenEnabled) {
hidePendingAutoOpenOverlay();
return;
}
if (pendingProcessingBatch) {
return;
}
if (pendingAutoOpenTriggered) {
return;
}
const { items: candidates } = getPendingVisibleCandidates();
if (!candidates.length) {
hidePendingAutoOpenOverlay();
return;
}
if (pendingAutoOpenTimerId) {
clearTimeout(pendingAutoOpenTimerId);
pendingAutoOpenTimerId = null;
}
hidePendingAutoOpenOverlay();
pendingAutoOpenTriggered = true;
const delay = typeof delayMs === 'number' ? Math.max(0, delayMs) : PENDING_AUTO_OPEN_DELAY_MS;
if (delay === 0) {
if (pendingAutoOpenEnabled) {
openPendingBatch({ auto: true });
} else {
pendingAutoOpenTriggered = false;
}
return;
}
showPendingAutoOpenOverlay(delay);
pendingAutoOpenTimerId = setTimeout(() => {
pendingAutoOpenTimerId = null;
hidePendingAutoOpenOverlay();
if (pendingAutoOpenEnabled) {
openPendingBatch({ auto: true });
} else {
pendingAutoOpenTriggered = false;
}
}, delay);
}
function cleanupLoadMoreObserver() {
if (loadMoreObserver && observedLoadMoreElement) {
loadMoreObserver.unobserve(observedLoadMoreElement);
@@ -1828,6 +2201,7 @@ function setTab(tab, { updateUrl = true } = {}) {
updateTabInUrl();
}
renderPosts();
maybeAutoOpenPending('tab');
}
function initializeTabFromUrl() {
@@ -3011,7 +3385,9 @@ function applyProfileNumber(profileNumber, { fromBackend = false } = {}) {
}
resetVisibleCount();
pendingOpenCooldownMap = loadPendingOpenCooldownMap(currentProfile);
renderPosts();
maybeAutoOpenPending('profile');
}
// Load profile from localStorage
@@ -3066,6 +3442,42 @@ if (includeExpiredToggle) {
});
}
if (pendingBulkCountSelect) {
pendingBulkCountSelect.value = String(pendingBulkCount);
pendingBulkCountSelect.addEventListener('change', () => {
const value = parseInt(pendingBulkCountSelect.value, 10);
if (!Number.isNaN(value)) {
pendingBulkCount = value;
persistPendingBulkCount(value);
}
});
}
if (pendingBulkOpenBtn) {
pendingBulkOpenBtn.addEventListener('click', () => openPendingBatch());
}
if (pendingAutoOpenOverlayPanel) {
pendingAutoOpenOverlayPanel.addEventListener('click', () => cancelPendingAutoOpen(true));
}
if (pendingAutoOpenToggle) {
pendingAutoOpenToggle.checked = !!pendingAutoOpenEnabled;
pendingAutoOpenToggle.addEventListener('change', () => {
pendingAutoOpenEnabled = pendingAutoOpenToggle.checked;
persistPendingAutoOpenEnabled(pendingAutoOpenEnabled);
pendingAutoOpenTriggered = false;
if (!pendingAutoOpenEnabled && pendingAutoOpenTimerId) {
cancelPendingAutoOpen(false);
}
if (pendingAutoOpenEnabled) {
maybeAutoOpenPending('toggle');
} else {
hidePendingAutoOpenOverlay();
}
});
}
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
@@ -3164,6 +3576,11 @@ if (sortModeSelect) {
sortModeSelect.addEventListener('change', () => {
const value = sortModeSelect.value;
sortMode = VALID_SORT_MODES.has(value) ? value : DEFAULT_SORT_SETTINGS.mode;
const defaultDirection = getDefaultSortDirectionForMode(sortMode);
if (defaultDirection) {
sortDirection = defaultDirection;
updateSortDirectionToggleUI();
}
saveSortMode();
resetVisibleCount();
renderPosts();
@@ -3201,6 +3618,7 @@ async function fetchPosts({ showLoader = true } = {}) {
}
isFetchingPosts = true;
cancelPendingAutoOpen(false);
try {
if (showLoader) {
@@ -3218,6 +3636,7 @@ async function fetchPosts({ showLoader = true } = {}) {
await normalizeLoadedPostUrls();
sortPostsByCreatedAt();
renderPosts();
maybeAutoOpenPending('load');
} catch (error) {
if (showLoader) {
showError('Fehler beim Laden der Beiträge. Stelle sicher, dass das Backend läuft.');
@@ -3487,45 +3906,18 @@ function renderPosts() {
updateTabButtons();
cleanupLoadMoreObserver();
const postItems = posts.map((post) => ({
post,
status: computePostStatus(post)
}));
const {
sortedItems,
filteredItems: filteredItemsResult,
tabTotalCount,
searchActive
} = getPostListState();
const sortedItems = [...postItems].sort(comparePostItems);
let filteredItems = filteredItemsResult;
const focusCandidateEntry = (!focusHandled && (focusPostIdParam || focusNormalizedUrl))
? sortedItems.find((item) => doesPostMatchFocus(item.post))
: null;
let filteredItems = sortedItems;
if (currentTab === 'pending') {
filteredItems = sortedItems.filter((item) => !item.status.isExpired && item.status.canCurrentProfileCheck && !item.status.isComplete);
} else {
filteredItems = includeExpiredPosts
? sortedItems
: sortedItems.filter((item) => !item.status.isExpired && !item.status.isComplete);
}
const tabTotalCount = filteredItems.length;
const searchInput = document.getElementById('searchInput');
const searchValue = searchInput && typeof searchInput.value === 'string' ? searchInput.value.trim() : '';
const searchActive = Boolean(searchValue);
if (searchActive) {
const searchTerm = searchValue.toLowerCase();
filteredItems = filteredItems.filter((item) => {
const post = item.post;
return (
(post.title && post.title.toLowerCase().includes(searchTerm)) ||
(post.url && post.url.toLowerCase().includes(searchTerm)) ||
(post.created_by_name && post.created_by_name.toLowerCase().includes(searchTerm)) ||
(post.id && post.id.toLowerCase().includes(searchTerm))
);
});
}
if (!focusHandled && focusCandidateEntry && !searchActive) {
const candidateVisibleInCurrentTab = filteredItems.some(({ post }) => doesPostMatchFocus(post));
if (!candidateVisibleInCurrentTab) {
@@ -3554,6 +3946,7 @@ function renderPosts() {
}
updateFilteredCount(currentTab, filteredItems.length);
updatePendingBulkControls(filteredItems.length);
const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab));
const visibleItems = filteredItems.slice(0, visibleCount);
@@ -4597,6 +4990,26 @@ window.addEventListener('resize', () => {
}
});
window.addEventListener('app:view-change', (event) => {
const view = event && event.detail ? event.detail.view : null;
if (view === 'posts') {
maybeAutoOpenPending('view');
} else {
cancelPendingAutoOpen(false);
}
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && pendingAutoOpenEnabled) {
if (pendingAutoOpenTimerId) {
clearTimeout(pendingAutoOpenTimerId);
pendingAutoOpenTimerId = null;
}
pendingAutoOpenTriggered = false;
maybeAutoOpenPending('visibility', PENDING_AUTO_OPEN_DELAY_MS);
}
});
// Initialize
async function bootstrapApp() {
const authenticated = await ensureAuthenticated();

View File

@@ -178,11 +178,43 @@
</label>
<input type="text" id="searchInput" class="search-input" placeholder="Beiträge durchsuchen...">
</div>
<div class="posts-bulk-controls" id="pendingBulkControls" hidden>
<div class="bulk-actions">
<label for="pendingBulkCountSelect">Anzahl</label>
<select id="pendingBulkCountSelect">
<option value="1">1</option>
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
</select>
<label class="auto-open-toggle">
<input type="checkbox" id="pendingAutoOpenToggle">
<span>Auto öffnen</span>
</label>
<button type="button" class="btn btn-secondary" id="pendingBulkOpenBtn">Links öffnen</button>
</div>
<div id="pendingBulkStatus" class="bulk-status" role="status" aria-live="polite"></div>
</div>
</div>
<div id="loading" class="loading">Lade Beiträge...</div>
<div id="error" class="error" style="display: none;"></div>
<div id="pendingAutoOpenOverlay" class="auto-open-overlay" hidden>
<div class="auto-open-overlay__panel" id="pendingAutoOpenOverlayPanel">
<div class="auto-open-overlay__badge">Auto-Öffnen startet gleich</div>
<div class="auto-open-overlay__timer">
<span id="pendingAutoOpenCountdown" class="auto-open-overlay__count">0.0</span>
<span class="auto-open-overlay__unit">Sek.</span>
</div>
<p class="auto-open-overlay__text">
Die nächsten offenen Beiträge werden automatisch geöffnet. Abbrechen, falls du noch warten willst.
</p>
<p class="auto-open-overlay__hint">Klicke irgendwo in dieses Panel, um abzubrechen.</p>
</div>
</div>
<div id="postsContainer" class="posts-container"></div>
</div>
@@ -1181,6 +1213,36 @@
</form>
</section>
<!-- Similarity settings -->
<section class="settings-section">
<h2 class="section-title">Ähnlichkeits-Erkennung</h2>
<p class="section-description">
Steuert, ab wann Posts als ähnlich gelten (Text-Ähnlichkeit oder Bild-Ähnlichkeit).
</p>
<form id="similaritySettingsForm">
<div class="form-group">
<label for="similarityTextThreshold" class="form-label">Text-Ähnlichkeit (0.500.99)</label>
<input type="number" id="similarityTextThreshold" class="form-input" min="0.5" max="0.99" step="0.01" value="0.85">
<p class="form-help">
Je höher der Wert, desto strenger wird Text-Ähnlichkeit bewertet.
</p>
</div>
<div class="form-group">
<label for="similarityImageThreshold" class="form-label">Bild-Distanz (064)</label>
<input type="number" id="similarityImageThreshold" class="form-input" min="0" max="64" step="1" value="6">
<p class="form-help">
Kleiner Wert = strenger (0 = identischer Hash, 64 = komplett unterschiedlich).
</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Ähnlichkeit speichern</button>
</div>
</form>
</section>
<!-- Hidden posts / purge settings -->
<section class="settings-section">
<h2 class="section-title">Versteckte Beiträge bereinigen</h2>

View File

@@ -72,6 +72,10 @@ let moderationSettings = {
sports_terms: {},
sports_auto_hide_enabled: false
};
let similaritySettings = {
text_threshold: 0.85,
image_distance_threshold: 6
};
function handleUnauthorized(response) {
if (response && response.status === 401) {
@@ -405,6 +409,84 @@ async function saveModerationSettings(event, { silent = false } = {}) {
}
}
function normalizeSimilarityTextThresholdInput(value) {
const parsed = parseFloat(value);
if (Number.isNaN(parsed)) {
return 0.85;
}
return Math.min(0.99, Math.max(0.5, parsed));
}
function normalizeSimilarityImageThresholdInput(value) {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) {
return 6;
}
return Math.min(64, Math.max(0, parsed));
}
function applySimilaritySettingsUI() {
const textInput = document.getElementById('similarityTextThreshold');
const imageInput = document.getElementById('similarityImageThreshold');
if (textInput) {
textInput.value = similaritySettings.text_threshold ?? 0.85;
}
if (imageInput) {
imageInput.value = similaritySettings.image_distance_threshold ?? 6;
}
}
async function loadSimilaritySettings() {
const res = await apiFetch(`${API_URL}/similarity-settings`);
if (!res.ok) throw new Error('Konnte Ähnlichkeits-Einstellungen nicht laden');
const data = await res.json();
similaritySettings = {
text_threshold: normalizeSimilarityTextThresholdInput(data.text_threshold),
image_distance_threshold: normalizeSimilarityImageThresholdInput(data.image_distance_threshold)
};
applySimilaritySettingsUI();
}
async function saveSimilaritySettings(event, { silent = false } = {}) {
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
const textInput = document.getElementById('similarityTextThreshold');
const imageInput = document.getElementById('similarityImageThreshold');
const textThreshold = textInput
? normalizeSimilarityTextThresholdInput(textInput.value)
: similaritySettings.text_threshold;
const imageThreshold = imageInput
? normalizeSimilarityImageThresholdInput(imageInput.value)
: similaritySettings.image_distance_threshold;
try {
const res = await apiFetch(`${API_URL}/similarity-settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text_threshold: textThreshold,
image_distance_threshold: imageThreshold
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Fehler beim Speichern');
}
similaritySettings = await res.json();
applySimilaritySettingsUI();
if (!silent) {
showSuccess('✅ Ähnlichkeitsregeln gespeichert');
}
return true;
} catch (err) {
if (!silent) {
showError('❌ ' + err.message);
}
return false;
}
}
function shorten(text, maxLength = 80) {
if (typeof text !== 'string') {
return '';
@@ -1041,6 +1123,7 @@ async function saveAllSettings(event) {
saveSettings(null, { silent: true }),
saveHiddenSettings(null, { silent: true }),
saveModerationSettings(null, { silent: true }),
saveSimilaritySettings(null, { silent: true }),
saveAllFriends({ silent: true })
]);
@@ -1208,12 +1291,30 @@ if (sportsScoringToggle && sportsScoreInput) {
}
}
const similarityForm = document.getElementById('similaritySettingsForm');
if (similarityForm) {
const textInput = document.getElementById('similarityTextThreshold');
const imageInput = document.getElementById('similarityImageThreshold');
if (textInput) {
textInput.addEventListener('blur', () => {
textInput.value = normalizeSimilarityTextThresholdInput(textInput.value);
});
}
if (imageInput) {
imageInput.addEventListener('blur', () => {
imageInput.value = normalizeSimilarityImageThresholdInput(imageInput.value);
});
}
similarityForm.addEventListener('submit', (e) => saveSimilaritySettings(e));
}
// Initialize
Promise.all([
loadCredentials(),
loadSettings(),
loadHiddenSettings(),
loadModerationSettings(),
loadSimilaritySettings(),
loadProfileFriends()
]).catch(err => showError(err.message));
})();

View File

@@ -589,6 +589,141 @@ h1 {
margin: 0 4px;
}
.posts-bulk-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
justify-content: space-between;
margin-top: 12px;
}
.bulk-actions {
display: inline-flex;
align-items: center;
gap: 8px;
background: #f8fafc;
border: 1px solid #e5e7eb;
padding: 8px 10px;
border-radius: 12px;
}
.bulk-actions label {
color: #6b7280;
font-size: 13px;
}
.bulk-actions select {
background: #ffffff;
border: 1px solid #e5e7eb;
color: #111827;
border-radius: 10px;
padding: 8px 10px;
}
.auto-open-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6b7280;
}
.auto-open-toggle input {
width: 16px;
height: 16px;
}
.bulk-status {
font-size: 13px;
color: #6b7280;
}
.bulk-status--error {
color: #dc2626;
}
.auto-open-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(circle at 20% 20%, rgba(37, 99, 235, 0.12), transparent 42%),
radial-gradient(circle at 80% 30%, rgba(6, 182, 212, 0.12), transparent 38%),
rgba(15, 23, 42, 0.6);
z-index: 30;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.auto-open-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.auto-open-overlay__panel {
width: min(940px, 100%);
background: linear-gradient(150deg, rgba(255, 255, 255, 0.9), rgba(248, 250, 252, 0.96));
border-radius: 22px;
padding: 38px 42px 40px;
box-shadow: 0 32px 90px rgba(15, 23, 42, 0.4);
border: 1px solid rgba(255, 255, 255, 0.6);
text-align: center;
cursor: pointer;
}
.auto-open-overlay__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.12);
color: #0f172a;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 12px;
}
.auto-open-overlay__timer {
display: flex;
align-items: baseline;
justify-content: center;
gap: 12px;
margin: 18px 0 8px;
color: #0f172a;
}
.auto-open-overlay__count {
font-size: clamp(72px, 12vw, 120px);
line-height: 1;
font-weight: 700;
letter-spacing: -0.02em;
}
.auto-open-overlay__unit {
font-size: 22px;
color: #6b7280;
}
.auto-open-overlay__text {
margin: 0 auto;
color: #334155;
max-width: 700px;
font-size: 18px;
}
.auto-open-overlay__hint {
margin: 12px 0 0;
color: #475569;
font-size: 15px;
}
.posts-load-more {
display: flex;
justify-content: center;