diff --git a/backend/server.js b/backend/server.js index a2a890f..9886926 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2759,7 +2759,6 @@ app.post('/api/daily-bookmarks/import', (req, res) => { notes: '', marker: normalizedMarker }); - upsertDailyBookmarkCheckStmt.run({ bookmarkId: id, dayKey }); const saved = getDailyBookmarkStmt.get({ bookmarkId: id, dayKey }); if (saved) { createdItems.push(saved); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..29f6271 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,98 @@ +{ + "name": "fb", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@babel/parser": "^7.28.5", + "acorn": "^8.15.0", + "acorn-loose": "^8.5.2", + "esprima": "^4.0.1" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-loose": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", + "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb71ac8 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "@babel/parser": "^7.28.5", + "acorn": "^8.15.0", + "acorn-loose": "^8.5.2", + "esprima": "^4.0.1" + } +} diff --git a/web/daily-bookmarks.css b/web/daily-bookmarks.css index 1402f3c..13006e5 100644 --- a/web/daily-bookmarks.css +++ b/web/daily-bookmarks.css @@ -120,6 +120,87 @@ a:hover { border: 1px solid rgba(37, 99, 235, 0.15); } +.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: var(--muted); +} + +.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; +} + .hero__controls { display: flex; flex-wrap: wrap; @@ -181,6 +262,19 @@ a:hover { font-size: 13px; } +.auto-open-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--muted); +} + +.auto-open-toggle input { + width: 16px; + height: 16px; +} + .bulk-actions select { background: #fff; border: 1px solid var(--border); @@ -477,6 +571,10 @@ a:hover { width: 180px; } +.bookmark-table th.col-created { + width: 160px; +} + .bookmark-table th.col-last { width: 220px; } @@ -659,6 +757,15 @@ a:hover { color: var(--text); } +.table-filter-row input[type="search"] { + width: 100%; + border-radius: 10px; + border: 1px solid var(--border); + padding: 8px 10px; + background: #fff; + color: var(--text); +} + .filter-hint { font-size: 12px; color: var(--muted); @@ -666,8 +773,7 @@ a:hover { letter-spacing: 0; display: flex; align-items: center; - gap: 8px; - justify-content: space-between; + justify-content: center; } .import-hint { diff --git a/web/daily-bookmarks.html b/web/daily-bookmarks.html index 72284e4..976f614 100644 --- a/web/daily-bookmarks.html +++ b/web/daily-bookmarks.html @@ -35,6 +35,10 @@ + @@ -45,6 +49,20 @@ + +
@@ -64,13 +82,19 @@ + + + Aktionen - + + + + - - Filter & Sortierung werden gespeichert - + + + + diff --git a/web/daily-bookmarks.js b/web/daily-bookmarks.js index 6b716b8..d7c1fe9 100644 --- a/web/daily-bookmarks.js +++ b/web/daily-bookmarks.js @@ -3,8 +3,10 @@ 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: 'updated_at', direction: 'desc' }; + const DEFAULT_SORT = { column: 'last_completed_at', direction: 'desc' }; + const AUTO_OPEN_DELAY_MS = 1500; const state = { dayKey: formatDayKey(new Date()), @@ -16,7 +18,11 @@ bulkCount: loadBulkCount(), filters: loadFilters(), sort: loadSort(), - importing: false + importing: false, + autoOpenEnabled: loadAutoOpenEnabled(), + autoOpenTriggered: false, + autoOpenTimerId: null, + autoOpenCountdownIntervalId: null }; let editingId = null; @@ -33,6 +39,10 @@ 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'); @@ -50,6 +60,7 @@ 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'); @@ -140,13 +151,14 @@ if (raw) { const parsed = JSON.parse(raw); return { - marker: typeof parsed.marker === 'string' ? parsed.marker : '' + marker: typeof parsed.marker === 'string' ? parsed.marker : '', + url: typeof parsed.url === 'string' ? parsed.url : '' }; } } catch (error) { // ignore } - return { marker: '' }; + return { marker: '', url: '' }; } function persistFilters(filters) { @@ -162,7 +174,7 @@ const raw = localStorage.getItem(SORT_STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw); - const allowedColumns = ['url_template', 'marker', 'updated_at', 'last_completed_at']; + const allowedColumns = ['url_template', 'marker', 'created_at', 'updated_at', 'last_completed_at']; const allowedDirections = ['asc', 'desc']; if ( parsed && @@ -186,6 +198,22 @@ } } + 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 ''; @@ -524,19 +552,44 @@ 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') { - return !currentMarker; + if (currentMarker) { + return false; + } + } else if (markerFilter) { + if (currentMarker !== markerFilter.toLowerCase()) { + return false; + } } - if (markerFilter) { - return currentMarker === markerFilter.toLowerCase(); + if (urlFilter) { + const urlValue = (item.resolved_url || item.url_template || '').toLowerCase(); + if (!urlValue.includes(urlFilter)) { + return false; + } } return true; }); @@ -557,6 +610,8 @@ 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': @@ -678,6 +733,10 @@ } 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); @@ -740,7 +799,7 @@ function appendSingleRow(text, className) { const tr = document.createElement('tr'); const td = document.createElement('td'); - td.colSpan = 4; + td.colSpan = 5; td.className = className; td.textContent = text; tr.appendChild(td); @@ -754,21 +813,121 @@ 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; + heroStats.textContent = `${text}${filterText}`; } if (listSummary) { - const markerInfo = state.filters.marker - ? state.filters.marker === '__none' - ? ' · Filter: ohne Marker' - : ` · Filter: ${state.filters.marker}` - : ''; - listSummary.textContent = `${text} – Tag ${state.dayKey}${markerInfo}`; + 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(); @@ -779,6 +938,7 @@ updateHeroStats(); renderMarkerFilterOptions(); renderTable(); + maybeAutoOpen('load'); } catch (error) { state.loading = false; state.error = 'Konnte Bookmarks nicht laden.'; @@ -897,11 +1057,16 @@ } } - async function openBatch() { + 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) { - setListStatus('Keine offenen Bookmarks für den gewählten Tag.', true); + if (!auto) { + setListStatus('Keine offenen Bookmarks für den gewählten Tag.', true); + } return; } @@ -909,8 +1074,14 @@ const selection = undone.slice(0, count); state.processingBatch = true; - bulkOpenBtn.disabled = true; - setListStatus(''); + 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; @@ -929,7 +1100,12 @@ } state.processingBatch = false; - bulkOpenBtn.disabled = false; + if (bulkOpenBtn) { + bulkOpenBtn.disabled = false; + } + if (auto) { + setListStatus(''); + } updateHeroStats(); renderTable(); } @@ -1097,8 +1273,38 @@ }); } if (bulkOpenBtn) { - bulkOpenBtn.addEventListener('click', openBatch); + 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 || ''; @@ -1107,13 +1313,25 @@ 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: '' }; + state.filters = { marker: '', url: '' }; state.sort = { ...DEFAULT_SORT }; persistFilters(state.filters); persistSort(state.sort); renderMarkerFilterOptions(); + if (urlFilterInput) { + urlFilterInput.value = ''; + } updateHeroStats(); renderTable(); }); @@ -1126,7 +1344,7 @@ if (state.sort.column === key) { state.sort.direction = state.sort.direction === 'asc' ? 'desc' : 'asc'; } else { - const defaultDirection = key === 'last_completed_at' || key === 'updated_at' ? 'desc' : 'asc'; + const defaultDirection = ['last_completed_at', 'updated_at', 'created_at'].includes(key) ? 'desc' : 'asc'; state.sort = { column: key, direction: defaultDirection }; } persistSort(state.sort);