Files
PostTracker/web/daily-bookmarks.js
2025-12-04 12:56:32 +01:00

1388 lines
42 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.

(function () {
const API_URL = window.API_URL || 'https://fb.srv.medeba-media.de/api';
const BULK_COUNT_STORAGE_KEY = 'dailyBookmarkBulkCount';
const FILTER_STORAGE_KEY = 'dailyBookmarkFilters';
const SORT_STORAGE_KEY = 'dailyBookmarkSort';
const AUTO_OPEN_STORAGE_KEY = 'dailyBookmarkAutoOpen';
const DEFAULT_BULK_COUNT = 5;
const DEFAULT_SORT = { column: 'last_completed_at', direction: 'desc' };
const AUTO_OPEN_DELAY_MS = 1500;
const state = {
dayKey: formatDayKey(new Date()),
items: [],
loading: false,
saving: false,
processingBatch: false,
error: '',
bulkCount: loadBulkCount(),
filters: loadFilters(),
sort: loadSort(),
importing: false,
autoOpenEnabled: loadAutoOpenEnabled(),
autoOpenTriggered: false,
autoOpenTimerId: null,
autoOpenCountdownIntervalId: null
};
let editingId = null;
const dayLabel = document.getElementById('dayLabel');
const daySubLabel = document.getElementById('daySubLabel');
const prevDayBtn = document.getElementById('prevDayBtn');
const nextDayBtn = document.getElementById('nextDayBtn');
const todayBtn = document.getElementById('todayBtn');
const refreshBtn = document.getElementById('refreshBtn');
const heroStats = document.getElementById('heroStats');
const listSummary = document.getElementById('listSummary');
const listStatus = document.getElementById('listStatus');
const tableBody = document.getElementById('tableBody');
const bulkCountSelect = document.getElementById('bulkCountSelect');
const bulkOpenBtn = document.getElementById('bulkOpenBtn');
const autoOpenToggle = document.getElementById('autoOpenToggle');
const autoOpenOverlay = document.getElementById('autoOpenOverlay');
const autoOpenOverlayPanel = document.getElementById('autoOpenOverlayPanel');
const autoOpenCountdown = document.getElementById('autoOpenCountdown');
const openCreateBtn = document.getElementById('openCreateBtn');
const modal = document.getElementById('bookmarkModal');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const modalBackdrop = modal ? modal.querySelector('.modal__backdrop') : null;
const formEl = document.getElementById('bookmarkForm');
const titleInput = document.getElementById('titleInput');
const urlInput = document.getElementById('urlInput');
const notesInput = document.getElementById('notesInput');
const resetBtn = document.getElementById('resetBtn');
const submitBtn = document.getElementById('submitBtn');
const previewLink = document.getElementById('previewLink');
const formStatus = document.getElementById('formStatus');
const formModeLabel = document.getElementById('formModeLabel');
const urlSuggestionBox = document.getElementById('urlSuggestionBox');
const markerInput = document.getElementById('markerInput');
const markerFilterSelect = document.getElementById('markerFilter');
const urlFilterInput = document.getElementById('urlFilter');
const resetViewBtn = document.getElementById('resetViewBtn');
const sortButtons = Array.from(document.querySelectorAll('[data-sort-key]'));
const openImportBtn = document.getElementById('openImportBtn');
const importModal = document.getElementById('importModal');
const importCloseBtn = document.getElementById('importCloseBtn');
const importBackdrop = importModal ? importModal.querySelector('.modal__backdrop') : null;
const importForm = document.getElementById('importForm');
const importInput = document.getElementById('importInput');
const importMarkerInput = document.getElementById('importMarkerInput');
const importResetBtn = document.getElementById('importResetBtn');
const importSubmitBtn = document.getElementById('importSubmitBtn');
const importStatus = document.getElementById('importStatus');
const PLACEHOLDER_PATTERN = /\{\{\s*([^}]+)\s*\}\}/gi;
function formatDayKey(date) {
const d = date instanceof Date ? date : new Date();
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function parseDayKey(dayKey) {
if (typeof dayKey !== 'string') {
return new Date();
}
const parts = dayKey.split('-').map((part) => parseInt(part, 10));
if (parts.length !== 3 || parts.some(Number.isNaN)) {
return new Date();
}
return new Date(parts[0], parts[1] - 1, parts[2]);
}
function addDays(date, delta) {
const base = date instanceof Date ? date : new Date();
const next = new Date(base);
next.setDate(base.getDate() + delta);
return next;
}
function formatDayLabel(dayKey) {
const date = parseDayKey(dayKey);
return date.toLocaleDateString('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric'
});
}
function formatRelativeDay(dayKey) {
const target = parseDayKey(dayKey);
const today = new Date();
const todayKey = formatDayKey(today);
const diff = Math.round((target.setHours(0, 0, 0, 0) - new Date(todayKey).setHours(0, 0, 0, 0)) / 86400000);
if (diff === 0) return 'Heute';
if (diff === 1) return 'Morgen';
if (diff === -1) return 'Gestern';
if (diff > 1) return `In ${diff} Tagen`;
return `${Math.abs(diff)} Tage her`;
}
function loadBulkCount() {
try {
const stored = localStorage.getItem(BULK_COUNT_STORAGE_KEY);
const value = parseInt(stored, 10);
if (!Number.isNaN(value) && [1, 5, 10, 15, 20].includes(value)) {
return value;
}
} catch (error) {
// ignore
}
return DEFAULT_BULK_COUNT;
}
function persistBulkCount(value) {
try {
localStorage.setItem(BULK_COUNT_STORAGE_KEY, String(value));
} catch (error) {
// ignore
}
}
function loadFilters() {
try {
const raw = localStorage.getItem(FILTER_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
marker: typeof parsed.marker === 'string' ? parsed.marker : '',
url: typeof parsed.url === 'string' ? parsed.url : ''
};
}
} catch (error) {
// ignore
}
return { marker: '', url: '' };
}
function persistFilters(filters) {
try {
localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify(filters || {}));
} catch (error) {
// ignore
}
}
function loadSort() {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
const allowedColumns = ['url_template', 'marker', 'created_at', 'updated_at', 'last_completed_at'];
const allowedDirections = ['asc', 'desc'];
if (
parsed &&
allowedColumns.includes(parsed.column) &&
allowedDirections.includes(parsed.direction)
) {
return parsed;
}
}
} catch (error) {
// ignore
}
return { ...DEFAULT_SORT };
}
function persistSort(sort) {
try {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(sort || DEFAULT_SORT));
} catch (error) {
// ignore
}
}
function loadAutoOpenEnabled() {
try {
return localStorage.getItem(AUTO_OPEN_STORAGE_KEY) === '1';
} catch (error) {
return false;
}
}
function persistAutoOpenEnabled(enabled) {
try {
localStorage.setItem(AUTO_OPEN_STORAGE_KEY, enabled ? '1' : '0');
} catch (error) {
// ignore
}
}
function resolveTemplate(template, dayKey) {
if (typeof template !== 'string') {
return '';
}
const baseDate = parseDayKey(dayKey);
return template.replace(PLACEHOLDER_PATTERN, (_match, contentRaw) => {
const content = (contentRaw || '').trim();
if (!content) {
return '';
}
const counterMatch = content.match(/^counter:\s*([+-]?\d+)([+-]\d+)?$/i);
if (counterMatch) {
const base = parseInt(counterMatch[1], 10);
const offset = counterMatch[2] ? parseInt(counterMatch[2], 10) || 0 : 0;
if (Number.isNaN(base)) {
return '';
}
const date = addDays(baseDate, offset);
return String(base + date.getDate());
}
const placeholderMatch = content.match(/^(date|day|dd|mm|month|yyyy|yy)([+-]\d+)?$/i);
if (!placeholderMatch) {
return content;
}
const token = String(placeholderMatch[1] || '').toLowerCase();
const offset = placeholderMatch[2] ? parseInt(placeholderMatch[2], 10) || 0 : 0;
const date = addDays(baseDate, offset);
switch (token) {
case 'date':
return formatDayKey(date);
case 'day':
return String(date.getDate());
case 'dd':
return String(date.getDate()).padStart(2, '0');
case 'month':
case 'mm':
return String(date.getMonth() + 1).padStart(2, '0');
case 'yyyy':
return String(date.getFullYear());
case 'yy':
return String(date.getFullYear()).slice(-2);
default:
return token;
}
});
}
function isDelimiter(char) {
return !char || /[\\/_\\-\\.\\?#&=]/.test(char);
}
function buildUrlSuggestions(urlValue) {
const suggestions = [];
if (!urlValue || typeof urlValue !== 'string') {
return suggestions;
}
const raw = urlValue.trim();
if (!raw) {
return suggestions;
}
const today = parseDayKey(state.dayKey);
const day = today.getDate();
const yearFull = String(today.getFullYear());
const yearShort = yearFull.slice(-2);
const monthNumber = today.getMonth() + 1;
const tokens = [];
const addToken = (text, placeholder) => {
if (!text) return;
tokens.push({ text, placeholder });
};
const dayText = String(day);
addToken(dayText, '{{day}}');
const dayPadded = dayText.padStart(2, '0');
if (dayPadded !== dayText) {
addToken(dayPadded, '{{dd}}');
}
const monthText = String(monthNumber);
addToken(monthText, '{{mm}}');
const monthPadded = monthText.padStart(2, '0');
if (monthPadded !== monthText) {
addToken(monthPadded, '{{mm}}');
}
addToken(yearFull, '{{yyyy}}');
addToken(yearShort, '{{yy}}');
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function findOccurrences(text, tokenText) {
const pattern = new RegExp(`(^|[^0-9])(${escapeRegex(tokenText)})(?=$|[^0-9])`, 'g');
const occurrences = [];
let match;
while ((match = pattern.exec(text)) !== null) {
const prefixLength = (match[1] || '').length;
const startIndex = match.index + prefixLength;
occurrences.push({ index: startIndex, length: tokenText.length });
}
return occurrences;
}
for (const token of tokens) {
const occ = findOccurrences(raw, token.text);
if (!occ.length) {
continue;
}
const candidate = occ[occ.length - 1]; // prefer last occurrence (z.B. am URL-Ende)
const replaced = `${raw.slice(0, candidate.index)}${token.placeholder}${raw.slice(candidate.index + candidate.length)}`;
const label = `Ersetze „${token.text}“ durch ${token.placeholder}`;
if (!suggestions.some((s) => s.label === label && s.value === replaced)) {
suggestions.push({
label,
value: replaced
});
}
}
return suggestions;
}
function renderUrlSuggestions() {
if (!urlSuggestionBox) {
return;
}
const suggestions = buildUrlSuggestions(urlInput ? urlInput.value : '');
urlSuggestionBox.innerHTML = '';
if (!suggestions.length) {
urlSuggestionBox.hidden = true;
return;
}
const text = document.createElement('span');
text.className = 'suggestion-box__text';
text.textContent = 'Mögliche Platzhalter:';
urlSuggestionBox.appendChild(text);
const applySuggestion = (value) => {
if (!urlInput) {
return;
}
urlInput.value = value;
updatePreviewLink();
renderUrlSuggestions();
const end = urlInput.value.length;
urlInput.focus();
try {
urlInput.setSelectionRange(end, end);
} catch (error) {
// ignore if not supported
}
};
suggestions.forEach((sugg) => {
const item = document.createElement('div');
item.className = 'suggestion-box__item';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'suggestion-btn';
btn.textContent = sugg.label;
btn.addEventListener('click', () => {
applySuggestion(sugg.value);
});
item.appendChild(btn);
const preview = document.createElement('a');
preview.className = 'suggestion-preview';
preview.href = resolveTemplate(sugg.value, state.dayKey) || sugg.value;
preview.target = '_blank';
preview.rel = 'noopener';
preview.title = resolveTemplate(sugg.value, state.dayKey) || sugg.value;
preview.textContent = resolveTemplate(sugg.value, state.dayKey) || sugg.value;
preview.addEventListener('click', (event) => {
event.preventDefault();
applySuggestion(sugg.value);
});
item.appendChild(preview);
urlSuggestionBox.appendChild(item);
});
urlSuggestionBox.hidden = false;
}
async function apiFetch(url, options = {}) {
const response = await fetch(url, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
}
});
if (!response.ok) {
const message = `Fehler: HTTP ${response.status}`;
throw new Error(message);
}
if (response.status === 204) {
return null;
}
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch (error) {
return null;
}
}
function updateDayUI() {
if (dayLabel) {
dayLabel.textContent = formatDayLabel(state.dayKey);
}
if (daySubLabel) {
daySubLabel.textContent = formatRelativeDay(state.dayKey);
}
updatePreviewLink();
}
function setDayKey(dayKey) {
state.dayKey = formatDayKey(parseDayKey(dayKey));
updateDayUI();
loadDailyBookmarks();
}
function setFormStatus(message, isError = false) {
if (!formStatus) {
return;
}
formStatus.textContent = message || '';
formStatus.classList.toggle('form-status--error', !!isError);
}
function setListStatus(message, isError = false) {
if (!listStatus) {
return;
}
listStatus.textContent = message || '';
listStatus.classList.toggle('list-status--error', !!isError);
}
function updatePreviewLink() {
if (!previewLink || !urlInput) {
return;
}
const resolved = resolveTemplate(urlInput.value || '', state.dayKey);
previewLink.textContent = resolved || '';
if (resolved) {
previewLink.href = resolved;
previewLink.target = '_blank';
previewLink.rel = 'noopener';
} else {
previewLink.removeAttribute('href');
}
renderUrlSuggestions();
}
function resetForm() {
editingId = null;
formModeLabel.textContent = 'Neues Bookmark';
submitBtn.textContent = 'Speichern';
formEl.reset();
if (markerInput) {
markerInput.value = '';
}
setFormStatus('');
updatePreviewLink();
renderUrlSuggestions();
}
function openModal(mode, bookmark) {
if (importModal && !importModal.hidden) {
closeImportModal();
}
if (mode === 'edit' && bookmark) {
editingId = bookmark.id;
formModeLabel.textContent = 'Bookmark bearbeiten';
submitBtn.textContent = 'Aktualisieren';
titleInput.value = bookmark.title || '';
urlInput.value = bookmark.url_template || '';
notesInput.value = bookmark.notes || '';
if (markerInput) {
markerInput.value = bookmark.marker || '';
}
setFormStatus('Bearbeite vorhandenes Bookmark');
} else {
resetForm();
}
if (modal) {
modal.hidden = false;
modal.focus();
}
updatePreviewLink();
if (mode === 'edit' && titleInput) {
titleInput.focus();
} else if (urlInput) {
urlInput.focus();
}
}
function closeModal() {
if (modal) {
modal.hidden = true;
}
resetForm();
}
function formatRelativeTimestamp(value) {
if (!value) return 'Noch nie erledigt';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Zeitpunkt unbekannt';
}
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'Gerade erledigt';
if (diffMin < 60) return `Vor ${diffMin} Min`;
const diffHours = Math.floor(diffMin / 60);
if (diffHours < 24) return `Vor ${diffHours} Std`;
const diffDays = Math.floor(diffHours / 24);
return `Vor ${diffDays} Tagen`;
}
function formatDateStamp(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function normalizeMarkerValue(value) {
return (value || '').trim();
}
function getFilteredItems() {
const markerFilter = state.filters.marker || '';
const urlFilter = (state.filters.url || '').toLowerCase();
return state.items.filter((item) => {
const currentMarker = normalizeMarkerValue(item.marker).toLowerCase();
if (markerFilter === '__none') {
if (currentMarker) {
return false;
}
} else if (markerFilter) {
if (currentMarker !== markerFilter.toLowerCase()) {
return false;
}
}
if (urlFilter) {
const urlValue = (item.resolved_url || item.url_template || '').toLowerCase();
if (!urlValue.includes(urlFilter)) {
return false;
}
}
return true;
});
}
function sortItems(items) {
const sorted = [...items];
const activeSort = state.sort || DEFAULT_SORT;
const direction = activeSort.direction === 'asc' ? 1 : -1;
const safeTime = (value) => {
const time = value ? new Date(value).getTime() : 0;
return Number.isNaN(time) ? 0 : time;
};
sorted.sort((a, b) => {
switch (activeSort.column) {
case 'url_template':
return a.url_template.localeCompare(b.url_template, 'de', { sensitivity: 'base' }) * direction;
case 'marker':
return normalizeMarkerValue(a.marker).localeCompare(normalizeMarkerValue(b.marker), 'de', { sensitivity: 'base' }) * direction;
case 'created_at':
return (safeTime(a.created_at) - safeTime(b.created_at)) * direction;
case 'updated_at':
return (safeTime(a.updated_at) - safeTime(b.updated_at)) * direction;
case 'last_completed_at':
default:
return (safeTime(a.last_completed_at) - safeTime(b.last_completed_at)) * direction;
}
});
return sorted;
}
function getVisibleItems() {
return sortItems(getFilteredItems());
}
function updateSortIndicators() {
if (!sortButtons || !sortButtons.length) {
return;
}
const activeSort = state.sort || DEFAULT_SORT;
sortButtons.forEach((btn) => {
const key = btn.dataset.sortKey;
const isActive = key === activeSort.column;
btn.classList.toggle('is-active', isActive);
btn.setAttribute('data-sort-direction', isActive ? (activeSort.direction === 'asc' ? '↑' : '↓') : '↕');
});
}
function renderMarkerFilterOptions() {
if (!markerFilterSelect) {
return;
}
const activeValue = state.filters.marker || '';
const markers = Array.from(
new Set(
state.items
.map((item) => normalizeMarkerValue(item.marker))
.filter(Boolean)
)
).sort((a, b) => a.localeCompare(b, 'de', { sensitivity: 'base' }));
markerFilterSelect.innerHTML = '';
const addOption = (value, label) => {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
markerFilterSelect.appendChild(opt);
};
addOption('', 'Alle Marker');
addOption('__none', 'Ohne Marker');
markers.forEach((marker) => addOption(marker, marker));
if (activeValue && activeValue !== '__none' && !markers.includes(activeValue)) {
addOption(activeValue, `${activeValue} (kein Treffer)`);
}
const nextValue = activeValue || '';
markerFilterSelect.value = nextValue;
state.filters.marker = nextValue;
persistFilters(state.filters);
}
function renderTable() {
if (!tableBody) {
return;
}
tableBody.innerHTML = '';
updateSortIndicators();
if (state.loading) {
appendSingleRow('Lade Bookmarks...', 'loading-state');
return;
}
if (state.error) {
appendSingleRow(state.error, 'error-state');
return;
}
if (!state.items.length) {
appendSingleRow('Keine Bookmarks vorhanden. Lege dein erstes Bookmark an.', 'empty-state');
return;
}
const visibleItems = getVisibleItems();
if (!visibleItems.length) {
appendSingleRow('Keine Bookmarks für diesen Filter.', 'empty-state');
return;
}
visibleItems.forEach((item) => {
const tr = document.createElement('tr');
tr.classList.add(item.completed_for_day ? 'is-done' : 'is-open');
const urlTd = document.createElement('td');
urlTd.className = 'url-cell';
const link = document.createElement('a');
const resolvedUrl = item.resolved_url || item.url_template;
link.href = resolvedUrl;
link.target = '_blank';
link.rel = 'noopener';
link.title = resolvedUrl;
link.textContent = resolvedUrl;
urlTd.appendChild(link);
tr.appendChild(urlTd);
const markerTd = document.createElement('td');
markerTd.className = 'marker-cell';
if (normalizeMarkerValue(item.marker)) {
const markerChip = document.createElement('span');
markerChip.className = 'chip chip--marker';
markerChip.textContent = item.marker;
markerTd.appendChild(markerChip);
} else {
markerTd.textContent = '';
markerTd.classList.add('muted');
}
tr.appendChild(markerTd);
const createdTd = document.createElement('td');
createdTd.textContent = formatDateStamp(item.created_at);
tr.appendChild(createdTd);
const timeTd = document.createElement('td');
timeTd.textContent = formatRelativeTimestamp(item.last_completed_at);
tr.appendChild(timeTd);
const actionsTd = document.createElement('td');
actionsTd.className = 'table-actions';
const openBtn = document.createElement('button');
openBtn.className = 'ghost-btn';
openBtn.type = 'button';
openBtn.textContent = '🔗';
openBtn.title = 'Öffnen';
openBtn.addEventListener('click', () => {
const target = item.resolved_url || item.url_template;
if (target) {
window.open(target, '_blank', 'noopener');
}
});
actionsTd.appendChild(openBtn);
const toggleBtn = document.createElement('button');
toggleBtn.className = 'ghost-btn';
toggleBtn.type = 'button';
toggleBtn.textContent = item.completed_for_day ? '↩️' : '✅';
toggleBtn.title = item.completed_for_day ? 'Zurücksetzen' : 'Heute erledigt';
toggleBtn.addEventListener('click', () => {
if (item.completed_for_day) {
undoDailyBookmark(item.id);
} else {
completeDailyBookmark(item.id);
}
});
actionsTd.appendChild(toggleBtn);
const editBtn = document.createElement('button');
editBtn.className = 'ghost-btn';
editBtn.type = 'button';
editBtn.textContent = '✏️';
editBtn.title = 'Bearbeiten';
editBtn.addEventListener('click', () => openModal('edit', item));
actionsTd.appendChild(editBtn);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'ghost-btn danger';
deleteBtn.type = 'button';
deleteBtn.textContent = '🗑️';
deleteBtn.title = 'Löschen';
deleteBtn.addEventListener('click', () => {
if (window.confirm('Bookmark wirklich löschen?')) {
deleteDailyBookmark(item.id);
}
});
actionsTd.appendChild(deleteBtn);
tr.appendChild(actionsTd);
tableBody.appendChild(tr);
});
}
function appendSingleRow(text, className) {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 5;
td.className = className;
td.textContent = text;
tr.appendChild(td);
tableBody.appendChild(tr);
}
function updateHeroStats() {
const total = state.items.length;
const done = state.items.filter((item) => item.completed_for_day).length;
const visibleItems = getFilteredItems();
const visibleDone = visibleItems.filter((item) => item.completed_for_day).length;
const filterSuffix = visibleItems.length !== total ? ` · Gefiltert: ${visibleDone}/${visibleItems.length}` : '';
const text = total ? `${done}/${total} erledigt${filterSuffix}` : 'Keine Bookmarks vorhanden';
const filterParts = [];
if (state.filters.marker) {
filterParts.push(state.filters.marker === '__none' ? 'ohne Marker' : `Marker: ${state.filters.marker}`);
}
if (state.filters.url) {
filterParts.push(`URL enthält „${state.filters.url}`);
}
const filterText = filterParts.length ? ` · Filter: ${filterParts.join(' · ')}` : '';
if (heroStats) {
heroStats.textContent = `${text}${filterText}`;
}
if (listSummary) {
listSummary.textContent = `${text} Tag ${state.dayKey}${filterText}`;
}
}
function clearAutoOpenCountdown() {
if (state.autoOpenCountdownIntervalId) {
clearInterval(state.autoOpenCountdownIntervalId);
state.autoOpenCountdownIntervalId = null;
}
}
function updateAutoOpenCountdownLabel(remainingMs) {
if (!autoOpenCountdown) return;
const safeMs = Math.max(0, remainingMs);
const seconds = safeMs / 1000;
const formatted = seconds >= 10 ? seconds.toFixed(0) : seconds.toFixed(1);
autoOpenCountdown.textContent = formatted;
}
function hideAutoOpenOverlay() {
clearAutoOpenCountdown();
if (autoOpenOverlay) {
autoOpenOverlay.classList.remove('visible');
autoOpenOverlay.hidden = true;
}
}
function showAutoOpenOverlay(delayMs) {
if (!autoOpenOverlay) return;
const duration = Math.max(0, delayMs);
hideAutoOpenOverlay();
autoOpenOverlay.hidden = false;
requestAnimationFrame(() => autoOpenOverlay.classList.add('visible'));
updateAutoOpenCountdownLabel(duration);
const start = Date.now();
state.autoOpenCountdownIntervalId = setInterval(() => {
const remaining = Math.max(0, duration - (Date.now() - start));
updateAutoOpenCountdownLabel(remaining);
if (remaining <= 0) {
clearAutoOpenCountdown();
}
}, 100);
}
function cancelAutoOpen(showMessage = false) {
if (state.autoOpenTimerId) {
clearTimeout(state.autoOpenTimerId);
state.autoOpenTimerId = null;
}
state.autoOpenTriggered = false;
hideAutoOpenOverlay();
if (showMessage) {
setListStatus('Automatisches Öffnen abgebrochen.', false);
}
}
function maybeAutoOpen(reason = '', delayMs = AUTO_OPEN_DELAY_MS) {
if (!state.autoOpenEnabled) {
hideAutoOpenOverlay();
return;
}
if (state.processingBatch) return;
if (state.autoOpenTriggered) return;
const undone = getVisibleItems().filter((item) => !item.completed_for_day);
if (!undone.length) {
hideAutoOpenOverlay();
return;
}
if (state.autoOpenTimerId) {
clearTimeout(state.autoOpenTimerId);
state.autoOpenTimerId = null;
}
hideAutoOpenOverlay();
state.autoOpenTriggered = true;
const delay = typeof delayMs === 'number' ? Math.max(0, delayMs) : AUTO_OPEN_DELAY_MS;
if (delay === 0) {
if (state.autoOpenEnabled) {
openBatch({ auto: true });
} else {
state.autoOpenTriggered = false;
}
return;
}
showAutoOpenOverlay(delay);
state.autoOpenTimerId = setTimeout(() => {
state.autoOpenTimerId = null;
hideAutoOpenOverlay();
if (state.autoOpenEnabled) {
openBatch({ auto: true });
} else {
state.autoOpenTriggered = false;
}
}, delay);
}
async function loadDailyBookmarks() {
state.loading = true;
if (state.autoOpenTimerId) {
clearTimeout(state.autoOpenTimerId);
state.autoOpenTimerId = null;
}
hideAutoOpenOverlay();
state.autoOpenTriggered = false;
state.error = '';
setListStatus('');
renderTable();
try {
const data = await apiFetch(`${API_URL}/daily-bookmarks?day=${encodeURIComponent(state.dayKey)}`);
state.items = Array.isArray(data) ? data : [];
state.loading = false;
updateHeroStats();
renderMarkerFilterOptions();
renderTable();
maybeAutoOpen('load');
} catch (error) {
state.loading = false;
state.error = 'Konnte Bookmarks nicht laden.';
setListStatus(state.error, true);
renderTable();
}
}
async function completeDailyBookmark(id) {
if (!id) return;
state.error = '';
try {
const updated = await apiFetch(`${API_URL}/daily-bookmarks/${encodeURIComponent(id)}/check`, {
method: 'POST',
body: JSON.stringify({ day: state.dayKey })
});
state.items = state.items.map((item) => (item.id === id ? updated : item));
updateHeroStats();
renderTable();
} catch (error) {
state.error = 'Konnte nicht abhaken.';
setListStatus(state.error, true);
renderTable();
}
}
async function undoDailyBookmark(id) {
if (!id) return;
state.error = '';
try {
const updated = await apiFetch(`${API_URL}/daily-bookmarks/${encodeURIComponent(id)}/check?day=${encodeURIComponent(state.dayKey)}`, {
method: 'DELETE'
});
state.items = state.items.map((item) => (item.id === id ? updated : item));
updateHeroStats();
renderTable();
} catch (error) {
state.error = 'Konnte nicht zurücksetzen.';
setListStatus(state.error, true);
renderTable();
}
}
async function deleteDailyBookmark(id) {
if (!id) return;
state.error = '';
try {
await apiFetch(`${API_URL}/daily-bookmarks/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
state.items = state.items.filter((item) => item.id !== id);
if (editingId === id) {
resetForm();
}
updateHeroStats();
renderMarkerFilterOptions();
renderTable();
} catch (error) {
state.error = 'Konnte Bookmark nicht löschen.';
setListStatus(state.error, true);
renderTable();
}
}
async function submitForm(event) {
event.preventDefault();
if (state.saving) return;
const title = titleInput.value.trim();
const url = urlInput.value.trim();
const notes = notesInput.value.trim();
const marker = markerInput ? markerInput.value.trim() : '';
if (!url) {
setFormStatus('URL-Template ist Pflicht.', true);
urlInput.focus();
return;
}
state.saving = true;
submitBtn.disabled = true;
setFormStatus('');
const payload = {
title,
url_template: url,
notes,
marker,
day: state.dayKey
};
const targetUrl = editingId
? `${API_URL}/daily-bookmarks/${encodeURIComponent(editingId)}`
: `${API_URL}/daily-bookmarks`;
const method = editingId ? 'PUT' : 'POST';
try {
const saved = await apiFetch(targetUrl, {
method,
body: JSON.stringify(payload)
});
if (editingId) {
state.items = state.items.map((item) => (item.id === editingId ? saved : item));
} else {
state.items = [saved, ...state.items];
}
updateHeroStats();
renderMarkerFilterOptions();
renderTable();
closeModal();
} catch (error) {
setFormStatus('Speichern fehlgeschlagen.', true);
} finally {
state.saving = false;
submitBtn.disabled = false;
}
}
async function openBatch({ auto = false } = {}) {
if (state.processingBatch) return;
if (!auto) {
cancelAutoOpen(false);
}
const undone = getVisibleItems().filter((item) => !item.completed_for_day);
if (!undone.length) {
if (!auto) {
setListStatus('Keine offenen Bookmarks für den gewählten Tag.', true);
}
return;
}
const count = state.bulkCount || DEFAULT_BULK_COUNT;
const selection = undone.slice(0, count);
state.processingBatch = true;
if (bulkOpenBtn) {
bulkOpenBtn.disabled = true;
}
if (!auto) {
setListStatus('');
} else {
setListStatus(`Öffne automatisch ${selection.length} Links...`, false);
}
for (const item of selection) {
const target = item.resolved_url || item.url_template;
if (target) {
window.open(target, '_blank', 'noopener');
}
try {
const updated = await apiFetch(`${API_URL}/daily-bookmarks/${encodeURIComponent(item.id)}/check`, {
method: 'POST',
body: JSON.stringify({ day: state.dayKey })
});
state.items = state.items.map((entry) => (entry.id === item.id ? updated : entry));
} catch (error) {
setListStatus('Einige Bookmarks konnten nicht abgehakt werden.', true);
}
}
state.processingBatch = false;
if (bulkOpenBtn) {
bulkOpenBtn.disabled = false;
}
if (auto) {
setListStatus('');
}
updateHeroStats();
renderTable();
}
function setImportStatus(message, isError = false) {
if (!importStatus) {
return;
}
importStatus.textContent = message || '';
importStatus.classList.toggle('form-status--error', !!isError);
}
function resetImportForm() {
if (importForm) {
importForm.reset();
}
const suggestedMarker = state.filters.marker && state.filters.marker !== '__none' ? state.filters.marker : '';
if (importMarkerInput) {
importMarkerInput.value = suggestedMarker;
}
setImportStatus('');
}
function openImportModal() {
if (modal && !modal.hidden) {
closeModal();
}
resetImportForm();
if (importModal) {
importModal.hidden = false;
importModal.focus();
}
if (importInput) {
importInput.focus();
}
}
function closeImportModal() {
if (importModal) {
importModal.hidden = true;
}
resetImportForm();
}
async function submitImportForm(event) {
event.preventDefault();
if (state.importing) return;
const rawText = importInput ? importInput.value.trim() : '';
const marker = importMarkerInput ? importMarkerInput.value.trim() : '';
if (!rawText) {
setImportStatus('Bitte füge mindestens eine URL ein.', true);
if (importInput) {
importInput.focus();
}
return;
}
const urls = rawText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (!urls.length) {
setImportStatus('Keine gültigen Zeilen gefunden.', true);
return;
}
state.importing = true;
if (importSubmitBtn) {
importSubmitBtn.disabled = true;
}
setImportStatus('Import läuft...');
try {
const result = await apiFetch(`${API_URL}/daily-bookmarks/import`, {
method: 'POST',
body: JSON.stringify({ urls, marker, day: state.dayKey })
});
const importedItems = Array.isArray(result && result.items) ? result.items : [];
const importedIds = new Set(importedItems.map((entry) => entry.id));
const remaining = state.items.filter((item) => !importedIds.has(item.id));
state.items = [...importedItems, ...remaining];
const summaryParts = [];
const createdCount = result && typeof result.created === 'number' ? result.created : importedItems.length;
summaryParts.push(`${createdCount} neu`);
if (result && result.skipped_existing) summaryParts.push(`${result.skipped_existing} bereits vorhanden`);
if (result && result.skipped_invalid) summaryParts.push(`${result.skipped_invalid} ungültig`);
if (result && result.skipped_duplicates) summaryParts.push(`${result.skipped_duplicates} Duplikate`);
setListStatus(`Import fertig: ${summaryParts.join(' · ')}`);
updateHeroStats();
renderMarkerFilterOptions();
renderTable();
closeImportModal();
} catch (error) {
setImportStatus(error && error.message ? error.message : 'Import fehlgeschlagen.', true);
} finally {
state.importing = false;
if (importSubmitBtn) {
importSubmitBtn.disabled = false;
}
}
}
function setupEvents() {
if (prevDayBtn) {
prevDayBtn.addEventListener('click', () => setDayKey(formatDayKey(addDays(parseDayKey(state.dayKey), -1))));
}
if (nextDayBtn) {
nextDayBtn.addEventListener('click', () => setDayKey(formatDayKey(addDays(parseDayKey(state.dayKey), 1))));
}
if (todayBtn) {
todayBtn.addEventListener('click', () => setDayKey(formatDayKey(new Date())));
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => loadDailyBookmarks());
}
if (openCreateBtn) {
openCreateBtn.addEventListener('click', () => openModal('create'));
}
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeModal);
}
if (modal) {
modal.addEventListener('click', (event) => {
if (event.target === modal) {
closeModal();
}
});
}
if (modalBackdrop) {
modalBackdrop.addEventListener('click', closeModal);
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
if (modal && !modal.hidden) {
closeModal();
}
if (importModal && !importModal.hidden) {
closeImportModal();
}
}
});
if (urlInput) {
urlInput.addEventListener('input', updatePreviewLink);
urlInput.addEventListener('blur', renderUrlSuggestions);
}
if (formEl) {
formEl.addEventListener('submit', submitForm);
}
if (resetBtn) {
resetBtn.addEventListener('click', resetForm);
}
if (bulkCountSelect) {
bulkCountSelect.value = String(state.bulkCount);
bulkCountSelect.addEventListener('change', () => {
const value = parseInt(bulkCountSelect.value, 10);
if (!Number.isNaN(value)) {
state.bulkCount = value;
persistBulkCount(value);
}
});
}
if (bulkOpenBtn) {
bulkOpenBtn.addEventListener('click', () => openBatch());
}
if (autoOpenOverlayPanel) {
autoOpenOverlayPanel.addEventListener('click', () => cancelAutoOpen(true));
}
if (autoOpenToggle) {
autoOpenToggle.checked = !!state.autoOpenEnabled;
autoOpenToggle.addEventListener('change', () => {
state.autoOpenEnabled = autoOpenToggle.checked;
persistAutoOpenEnabled(state.autoOpenEnabled);
state.autoOpenTriggered = false;
if (!state.autoOpenEnabled && state.autoOpenTimerId) {
cancelAutoOpen(false);
}
if (state.autoOpenEnabled) {
maybeAutoOpen('toggle');
} else {
hideAutoOpenOverlay();
}
});
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && state.autoOpenEnabled) {
// Reset the guard so that returning to the tab can trigger the next batch.
if (state.autoOpenTimerId) {
clearTimeout(state.autoOpenTimerId);
state.autoOpenTimerId = null;
}
state.autoOpenTriggered = false;
maybeAutoOpen('visibility', AUTO_OPEN_DELAY_MS);
}
});
if (markerFilterSelect) {
markerFilterSelect.addEventListener('change', () => {
state.filters.marker = markerFilterSelect.value || '';
persistFilters(state.filters);
updateHeroStats();
renderTable();
});
}
if (urlFilterInput) {
urlFilterInput.value = state.filters.url || '';
urlFilterInput.addEventListener('input', () => {
state.filters.url = urlFilterInput.value.trim();
persistFilters(state.filters);
updateHeroStats();
renderTable();
});
}
if (resetViewBtn) {
resetViewBtn.addEventListener('click', () => {
state.filters = { marker: '', url: '' };
state.sort = { ...DEFAULT_SORT };
persistFilters(state.filters);
persistSort(state.sort);
renderMarkerFilterOptions();
if (urlFilterInput) {
urlFilterInput.value = '';
}
updateHeroStats();
renderTable();
});
}
if (sortButtons && sortButtons.length) {
sortButtons.forEach((btn) => {
btn.addEventListener('click', () => {
const key = btn.dataset.sortKey;
if (!key) return;
if (state.sort.column === key) {
state.sort.direction = state.sort.direction === 'asc' ? 'desc' : 'asc';
} else {
const defaultDirection = ['last_completed_at', 'updated_at', 'created_at'].includes(key) ? 'desc' : 'asc';
state.sort = { column: key, direction: defaultDirection };
}
persistSort(state.sort);
renderTable();
});
});
}
if (openImportBtn) {
openImportBtn.addEventListener('click', openImportModal);
}
if (importCloseBtn) {
importCloseBtn.addEventListener('click', closeImportModal);
}
if (importModal) {
importModal.addEventListener('click', (event) => {
if (event.target === importModal) {
closeImportModal();
}
});
}
if (importBackdrop) {
importBackdrop.addEventListener('click', closeImportModal);
}
if (importForm) {
importForm.addEventListener('submit', submitImportForm);
}
if (importResetBtn) {
importResetBtn.addEventListener('click', resetImportForm);
}
}
function init() {
updateDayUI();
setupEvents();
loadDailyBookmarks();
updatePreviewLink();
}
init();
})();