diff --git a/backend/server.js b/backend/server.js index 776e78d..2c1eefb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -24,6 +24,8 @@ const SEARCH_POST_HIDE_THRESHOLD = 2; const SEARCH_POST_RETENTION_DAYS = 90; const MAX_POST_TEXT_LENGTH = 4000; const MIN_TEXT_HASH_LENGTH = 120; +const MAX_BOOKMARK_LABEL_LENGTH = 120; +const MAX_BOOKMARK_QUERY_LENGTH = 200; const screenshotDir = path.join(__dirname, 'data', 'screenshots'); if (!fs.existsSync(screenshotDir)) { @@ -316,6 +318,48 @@ function computePostTextHash(text) { return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); } +function normalizeBookmarkQuery(value) { + if (typeof value !== 'string') { + return null; + } + + let query = value.trim(); + if (!query) { + return null; + } + + query = query.replace(/\s+/g, ' '); + if (query.length > MAX_BOOKMARK_QUERY_LENGTH) { + query = query.slice(0, MAX_BOOKMARK_QUERY_LENGTH); + } + return query; +} + +function normalizeBookmarkLabel(value, fallback = '') { + const base = typeof value === 'string' ? value.trim() : ''; + let label = base || fallback || ''; + label = label.replace(/\s+/g, ' '); + if (label.length > MAX_BOOKMARK_LABEL_LENGTH) { + label = label.slice(0, MAX_BOOKMARK_LABEL_LENGTH); + } + return label; +} + +function serializeBookmark(row) { + if (!row) { + return null; + } + + return { + id: row.id, + label: row.label, + query: row.query, + created_at: sqliteTimestampToUTC(row.created_at), + updated_at: sqliteTimestampToUTC(row.updated_at), + last_clicked_at: sqliteTimestampToUTC(row.last_clicked_at) + }; +} + function normalizeFacebookPostUrl(rawValue) { if (typeof rawValue !== 'string') { return null; @@ -655,6 +699,66 @@ db.exec(` ON search_seen_posts(last_seen_at); `); +db.exec(` + CREATE TABLE IF NOT EXISTS bookmarks ( + id TEXT PRIMARY KEY, + label TEXT, + query TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_clicked_at DATETIME, + UNIQUE(query COLLATE NOCASE) + ); +`); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_bookmarks_last_clicked_at + ON bookmarks(last_clicked_at); +`); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_bookmarks_created_at + ON bookmarks(created_at); +`); + +const listBookmarksStmt = db.prepare(` + SELECT id, label, query, created_at, updated_at, last_clicked_at + FROM bookmarks + ORDER BY + (last_clicked_at IS NULL), + datetime(COALESCE(last_clicked_at, created_at)) DESC, + label COLLATE NOCASE +`); + +const getBookmarkByIdStmt = db.prepare(` + SELECT id, label, query, created_at, updated_at, last_clicked_at + FROM bookmarks + WHERE id = ? +`); + +const findBookmarkByQueryStmt = db.prepare(` + SELECT id + FROM bookmarks + WHERE LOWER(query) = LOWER(?) +`); + +const insertBookmarkStmt = db.prepare(` + INSERT INTO bookmarks (id, label, query) + VALUES (?, ?, ?) +`); + +const deleteBookmarkStmt = db.prepare(` + DELETE FROM bookmarks + WHERE id = ? +`); + +const updateBookmarkLastClickedStmt = db.prepare(` + UPDATE bookmarks + SET last_clicked_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? +`); + ensureColumn('posts', 'checked_count', 'checked_count INTEGER DEFAULT 0'); ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT'); ensureColumn('posts', 'created_by_profile', 'created_by_profile INTEGER'); @@ -1693,6 +1797,79 @@ function mapPostRow(post) { }; } +app.get('/api/bookmarks', (req, res) => { + try { + const rows = listBookmarksStmt.all(); + res.json(rows.map(serializeBookmark)); + } catch (error) { + console.error('Failed to load bookmarks:', error); + res.status(500).json({ error: 'Bookmarks konnten nicht geladen werden' }); + } +}); + +app.post('/api/bookmarks', (req, res) => { + try { + const payload = req.body || {}; + const normalizedQuery = normalizeBookmarkQuery(payload.query); + if (!normalizedQuery) { + return res.status(400).json({ error: 'Ungültiger Suchbegriff' }); + } + + if (findBookmarkByQueryStmt.get(normalizedQuery)) { + return res.status(409).json({ error: 'Bookmark existiert bereits' }); + } + + const normalizedLabel = normalizeBookmarkLabel(payload.label, normalizedQuery); + const id = uuidv4(); + insertBookmarkStmt.run(id, normalizedLabel, normalizedQuery); + const saved = getBookmarkByIdStmt.get(id); + + res.status(201).json(serializeBookmark(saved)); + } catch (error) { + console.error('Failed to create bookmark:', error); + res.status(500).json({ error: 'Bookmark konnte nicht erstellt werden' }); + } +}); + +app.post('/api/bookmarks/:bookmarkId/click', (req, res) => { + const { bookmarkId } = req.params; + if (!bookmarkId) { + return res.status(400).json({ error: 'Bookmark-ID fehlt' }); + } + + try { + const result = updateBookmarkLastClickedStmt.run(bookmarkId); + if (!result.changes) { + return res.status(404).json({ error: 'Bookmark nicht gefunden' }); + } + + const updated = getBookmarkByIdStmt.get(bookmarkId); + res.json(serializeBookmark(updated)); + } catch (error) { + console.error('Failed to register bookmark click:', error); + res.status(500).json({ error: 'Bookmark konnte nicht aktualisiert werden' }); + } +}); + +app.delete('/api/bookmarks/:bookmarkId', (req, res) => { + const { bookmarkId } = req.params; + if (!bookmarkId) { + return res.status(400).json({ error: 'Bookmark-ID fehlt' }); + } + + try { + const result = deleteBookmarkStmt.run(bookmarkId); + if (!result.changes) { + return res.status(404).json({ error: 'Bookmark nicht gefunden' }); + } + + res.status(204).send(); + } catch (error) { + console.error('Failed to delete bookmark:', error); + res.status(500).json({ error: 'Bookmark konnte nicht gelöscht werden' }); + } +}); + // Get all posts app.get('/api/posts', (req, res) => { try { diff --git a/web/app.js b/web/app.js index ab1cb27..3fabf55 100644 --- a/web/app.js +++ b/web/app.js @@ -101,10 +101,8 @@ 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_STORAGE_KEY = 'trackerSearchBookmarks'; const BOOKMARKS_BASE_URL = 'https://www.facebook.com/search/top'; const BOOKMARK_WINDOW_DAYS = 28; -const DEFAULT_BOOKMARKS = []; const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen']; function initializeFocusParams() { @@ -279,53 +277,202 @@ function persistSortStorage(storage) { } } -function normalizeCustomBookmark(entry) { +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 (!query) { + if (!id || !query) { return null; } const label = typeof entry.label === 'string' && entry.label.trim() ? entry.label.trim() : query; - const id = typeof entry.id === 'string' && entry.id ? entry.id : `custom-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + + 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, - type: 'custom' + created_at: normalizeDate(entry.created_at), + updated_at: normalizeDate(entry.updated_at), + last_clicked_at: normalizeDate(entry.last_clicked_at), + deletable: true }; } -function loadCustomBookmarks() { - try { - const raw = localStorage.getItem(BOOKMARKS_STORAGE_KEY); - if (!raw) { - return []; +function deduplicateBookmarks(list) { + const seen = new Set(); + const deduped = []; + + list.forEach((bookmark) => { + if (!bookmark || !bookmark.query) { + return; } - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; + const key = bookmark.query.toLowerCase(); + if (seen.has(key)) { + return; } - return parsed.map(normalizeCustomBookmark).filter(Boolean); - } catch (error) { - console.warn('Konnte Bookmarks nicht laden:', error); - return []; - } + seen.add(key); + deduped.push(bookmark); + }); + + return deduped; } -function saveCustomBookmarks(bookmarks) { - try { - const sanitized = Array.isArray(bookmarks) - ? bookmarks.map(normalizeCustomBookmark).filter(Boolean) - : []; - localStorage.setItem(BOOKMARKS_STORAGE_KEY, JSON.stringify(sanitized)); - } catch (error) { - console.warn('Konnte Bookmarks nicht speichern:', error); +function 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' }); + }); +} + +const bookmarkState = { + items: [], + loaded: false, + loading: false, + error: null +}; + +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) { @@ -457,6 +604,22 @@ function openBookmark(bookmark) { queries.push(''); } + const stateBookmark = bookmarkState.items.find((item) => item.id === bookmark.id) || bookmark; + + if (stateBookmark && stateBookmark.id && stateBookmark.deletable !== false) { + const nowIso = new Date().toISOString(); + 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) { @@ -465,18 +628,114 @@ function openBookmark(bookmark) { }); } -function removeBookmark(bookmarkId) { +async function markBookmarkClick(bookmarkId) { if (!bookmarkId) { return; } - const current = loadCustomBookmarks(); - const next = current.filter((bookmark) => bookmark.id !== bookmarkId); - if (next.length === current.length) { + 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; } - saveCustomBookmarks(next); - renderBookmarks(); + + 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() { @@ -486,62 +745,97 @@ function renderBookmarks() { bookmarksList.innerHTML = ''; - const items = [...DEFAULT_BOOKMARKS, ...loadCustomBookmarks()]; - - const staticDefault = { - id: 'default-empty', - label: 'Gewinnspiel / gewinnen / verlosen', - query: '', - type: 'default' - }; - - items.unshift(staticDefault); - - if (!items.length) { - const empty = document.createElement('div'); - empty.className = 'bookmark-empty'; - empty.textContent = 'Noch keine Bookmarks vorhanden.'; - empty.setAttribute('role', 'listitem'); - bookmarksList.appendChild(empty); + 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; } - items.forEach((bookmark) => { - const item = document.createElement('div'); - item.className = 'bookmark-item'; - item.setAttribute('role', 'listitem'); + 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; + } - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'bookmark-button'; - const label = bookmark.label || bookmark.query; - button.textContent = label; + 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 searchVariants = buildBookmarkSearchQueries(bookmark.query); - if (searchVariants.length) { - button.title = searchVariants.map((variant) => `• ${variant}`).join('\n'); - } else { - button.title = `Suche nach "${bookmark.query}" (letzte 4 Wochen)`; + const dynamicBookmarks = bookmarkState.items; + + const staticDefault = { + id: 'default-search', + label: 'Gewinnspiel / gewinnen / verlosen', + query: '', + last_clicked_at: null, + deletable: false, + isDefault: true + }; + + const sorted = sortBookmarksByRecency(dynamicBookmarks); + const recent = []; + const RECENT_LIMIT = 5; + + sorted.forEach((bookmark) => { + if (bookmark.last_clicked_at && recent.length < RECENT_LIMIT) { + recent.push(bookmark); } - button.addEventListener('click', () => openBookmark(bookmark)); + }); - item.appendChild(button); + const alphabeticalAll = [...dynamicBookmarks] + .sort((a, b) => a.label.localeCompare(b.label, 'de', { sensitivity: 'base' })); - if (bookmark.type === 'custom') { - const removeBtn = document.createElement('button'); - removeBtn.type = 'button'; - removeBtn.className = 'bookmark-remove-btn'; - removeBtn.setAttribute('aria-label', `${bookmark.label || bookmark.query} entfernen`); - removeBtn.textContent = '×'; - removeBtn.addEventListener('click', (event) => { - event.preventDefault(); - event.stopPropagation(); - removeBookmark(bookmark.id); - }); - item.appendChild(removeBtn); + const sections = []; + + if (recent.length) { + sections.push({ + id: 'recent', + title: 'Zuletzt verwendet', + items: recent + }); + } + + sections.push({ + id: 'all', + title: 'Alle Bookmarks', + items: [staticDefault, ...alphabeticalAll] + }); + + sections.forEach((section) => { + if (!section.items.length) { + return; } - bookmarksList.appendChild(item); + const sectionElement = document.createElement('section'); + sectionElement.className = 'bookmark-section'; + sectionElement.dataset.section = section.id; + + const header = document.createElement('header'); + header.className = 'bookmark-section__header'; + + const title = document.createElement('h3'); + title.className = 'bookmark-section__title'; + title.textContent = section.title; + header.appendChild(title); + + sectionElement.appendChild(header); + + const list = document.createElement('div'); + list.className = 'bookmark-section__list'; + + section.items.forEach((bookmark) => { + list.appendChild(createBookmarkRow(bookmark)); + }); + + sectionElement.appendChild(list); + bookmarksList.appendChild(sectionElement); }); } @@ -604,7 +898,13 @@ function toggleBookmarkPanel(forceVisible) { bookmarkPanelToggle.setAttribute('aria-expanded', bookmarkPanelVisible ? 'true' : 'false'); if (bookmarkPanelVisible) { - renderBookmarks(); + if (!bookmarkState.loaded && !bookmarkState.loading) { + bookmarkState.loading = true; + renderBookmarks(); + refreshBookmarks().catch(() => {}); + } else { + renderBookmarks(); + } resetBookmarkForm(); if (bookmarkQueryInput) { window.requestAnimationFrame(() => { @@ -619,13 +919,14 @@ function toggleBookmarkPanel(forceVisible) { } else { resetBookmarkForm(); removeBookmarkOutsideHandler(); + bookmarkState.error = null; if (bookmarkPanelToggle) { bookmarkPanelToggle.focus(); } } } -function handleBookmarkSubmit(event) { +async function handleBookmarkSubmit(event) { event.preventDefault(); if (!bookmarkForm) { @@ -643,26 +944,44 @@ function handleBookmarkSubmit(event) { return; } - const customBookmarks = loadCustomBookmarks(); - const normalizedQuery = query.toLowerCase(); - const existingIndex = customBookmarks.findIndex((bookmark) => bookmark.query.toLowerCase() === normalizedQuery); + bookmarkState.error = null; - const nextBookmark = { - id: existingIndex >= 0 ? customBookmarks[existingIndex].id : `custom-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, - label: name || query, - query, - type: 'custom' - }; + try { + const response = await apiFetch(`${API_URL}/bookmarks`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + label: name, + query + }) + }); - if (existingIndex >= 0) { - customBookmarks[existingIndex] = nextBookmark; - } else { - customBookmarks.push(nextBookmark); + 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(); } - - saveCustomBookmarks(customBookmarks); - renderBookmarks(); - resetBookmarkForm(); } function initializeBookmarks() { @@ -670,7 +989,7 @@ function initializeBookmarks() { return; } - renderBookmarks(); + refreshBookmarks().catch(() => {}); if (bookmarkPanel) { bookmarkPanel.setAttribute('aria-hidden', 'true'); diff --git a/web/style.css b/web/style.css index 8db9cce..9244fce 100644 --- a/web/style.css +++ b/web/style.css @@ -1109,13 +1109,16 @@ h1 { position: absolute; top: calc(100% + 8px); right: 0; - width: min(420px, 90vw); + width: min(540px, 92vw); + max-height: 70vh; background: #ffffff; - border-radius: 10px; + border-radius: 12px; box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18); padding: 16px; z-index: 20; border: 1px solid rgba(229, 231, 235, 0.8); + display: flex; + flex-direction: column; } .bookmark-panel__header { @@ -1124,11 +1127,13 @@ h1 { align-items: center; gap: 12px; margin-bottom: 12px; + border-bottom: 1px solid rgba(229, 231, 235, 0.8); + padding-bottom: 8px; } .bookmark-panel__title { margin: 0; - font-size: 16px; + font-size: 15px; font-weight: 600; } @@ -1148,62 +1153,136 @@ h1 { } .bookmark-list { + flex: 1; + overflow-y: auto; display: flex; - flex-wrap: wrap; + flex-direction: column; + gap: 16px; + padding-right: 6px; +} + +.bookmark-section { + display: flex; + flex-direction: column; gap: 8px; } -.bookmark-item { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - background: #f0f2f5; - border-radius: 999px; +.bookmark-section__header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; } -.bookmark-button { - border: none; - background: transparent; - color: #1d2129; +.bookmark-section__title { + margin: 0; + font-size: 13px; font-weight: 600; - cursor: pointer; - display: inline-flex; - align-items: center; + color: #111827; +} + +.bookmark-section__hint { + font-size: 11px; + color: #6b7280; +} + +.bookmark-section__list { + display: flex; + flex-direction: column; gap: 6px; - padding: 0; - font-size: 14px; } -.bookmark-button:hover, -.bookmark-button:focus { - text-decoration: underline; +.bookmark-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + background: #f3f4f6; + border-radius: 8px; + padding: 7px 10px; } -.bookmark-button:focus-visible { - outline: 2px solid #2563eb; - outline-offset: 2px; +.bookmark-row[data-state="never-used"] { + background: #eef2ff; } -.bookmark-remove-btn { +.bookmark-row__open { border: none; background: transparent; - color: #7f8186; + text-align: left; + display: flex; + flex-direction: column; + gap: 2px; + cursor: pointer; + color: inherit; + padding: 0; +} + +.bookmark-row__open:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 3px; + border-radius: 6px; +} + +.bookmark-row__label { + font-size: 13px; + font-weight: 600; + color: #1f2937; +} + +.bookmark-row__query { + font-size: 11px; + color: #4b5563; +} + +.bookmark-row__meta { + font-size: 11px; + color: #6b7280; + white-space: nowrap; + justify-self: end; +} + +.bookmark-row__remove { + border: none; + background: transparent; + color: #9ca3af; cursor: pointer; font-size: 16px; line-height: 1; - padding: 0 2px; + padding: 0 6px; + border-radius: 6px; + transition: background-color 0.2s ease, color 0.2s ease; } -.bookmark-remove-btn:hover, -.bookmark-remove-btn:focus { - color: #c0392b; +.bookmark-row__remove:hover, +.bookmark-row__remove:focus-visible { + color: #ef4444; + background: rgba(239, 68, 68, 0.12); +} + +.bookmark-status { + font-size: 13px; + padding: 10px 12px; + border-radius: 8px; + background: #f3f4f6; + color: #374151; +} + +.bookmark-status--error { + background: #fee2e2; + color: #991b1b; + border: 1px solid rgba(248, 113, 113, 0.4); +} + +.bookmark-status--loading { + background: #ede9fe; + color: #4c1d95; } .bookmark-form { - margin-top: 12px; - border-top: 1px solid #e4e6eb; - padding-top: 12px; + margin-top: 16px; + border-top: 1px solid #e5e7eb; + padding-top: 14px; display: flex; flex-direction: column; gap: 12px; @@ -1246,11 +1325,27 @@ h1 { } .bookmark-empty { - font-size: 14px; - color: #65676b; - background: #f0f2f5; - border-radius: 8px; - padding: 12px; + font-size: 13px; + color: #4b5563; + background: #f3f4f6; + border-radius: 10px; + padding: 14px; + text-align: center; +} + +@media (max-width: 640px) { + .bookmark-panel { + width: min(480px, 94vw); + max-height: 75vh; + } + + .bookmark-section__list { + gap: 5px; + } + + .bookmark-row { + grid-template-columns: minmax(0, 1fr) auto; + } } .screenshot-modal { @@ -1434,6 +1529,12 @@ h1 { position: static; width: 100%; margin-top: 10px; + max-height: none; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12); + } + + .bookmark-list { + max-height: none; } .bookmark-form__actions {