bookmarks restyling

This commit is contained in:
MDeeApp
2025-10-23 21:22:41 +02:00
parent cd5a179125
commit 9d85044b7f
3 changed files with 736 additions and 139 deletions

View File

@@ -24,6 +24,8 @@ const SEARCH_POST_HIDE_THRESHOLD = 2;
const SEARCH_POST_RETENTION_DAYS = 90; const SEARCH_POST_RETENTION_DAYS = 90;
const MAX_POST_TEXT_LENGTH = 4000; const MAX_POST_TEXT_LENGTH = 4000;
const MIN_TEXT_HASH_LENGTH = 120; 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'); const screenshotDir = path.join(__dirname, 'data', 'screenshots');
if (!fs.existsSync(screenshotDir)) { if (!fs.existsSync(screenshotDir)) {
@@ -316,6 +318,48 @@ function computePostTextHash(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); 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) { function normalizeFacebookPostUrl(rawValue) {
if (typeof rawValue !== 'string') { if (typeof rawValue !== 'string') {
return null; return null;
@@ -655,6 +699,66 @@ db.exec(`
ON search_seen_posts(last_seen_at); 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', 'checked_count', 'checked_count INTEGER DEFAULT 0');
ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT'); ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT');
ensureColumn('posts', 'created_by_profile', 'created_by_profile INTEGER'); 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 // Get all posts
app.get('/api/posts', (req, res) => { app.get('/api/posts', (req, res) => {
try { try {

View File

@@ -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 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 VALID_SORT_MODES = new Set(['created', 'deadline', 'smart', 'lastCheck', 'lastChange']);
const DEFAULT_SORT_SETTINGS = { mode: 'created', direction: 'desc' }; const DEFAULT_SORT_SETTINGS = { mode: 'created', direction: 'desc' };
const BOOKMARKS_STORAGE_KEY = 'trackerSearchBookmarks';
const BOOKMARKS_BASE_URL = 'https://www.facebook.com/search/top'; const BOOKMARKS_BASE_URL = 'https://www.facebook.com/search/top';
const BOOKMARK_WINDOW_DAYS = 28; const BOOKMARK_WINDOW_DAYS = 28;
const DEFAULT_BOOKMARKS = [];
const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen']; const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen'];
function initializeFocusParams() { function initializeFocusParams() {
@@ -279,53 +277,202 @@ function persistSortStorage(storage) {
} }
} }
function normalizeCustomBookmark(entry) { function normalizeServerBookmark(entry) {
if (!entry || typeof entry !== 'object') { if (!entry || typeof entry !== 'object') {
return null; return null;
} }
const id = typeof entry.id === 'string' ? entry.id : null;
const query = typeof entry.query === 'string' ? entry.query.trim() : ''; const query = typeof entry.query === 'string' ? entry.query.trim() : '';
if (!query) { if (!id || !query) {
return null; return null;
} }
const label = typeof entry.label === 'string' && entry.label.trim() ? entry.label.trim() : query; 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 { return {
id, id,
label, label,
query, 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() { function deduplicateBookmarks(list) {
try { const seen = new Set();
const raw = localStorage.getItem(BOOKMARKS_STORAGE_KEY); const deduped = [];
if (!raw) {
return []; list.forEach((bookmark) => {
if (!bookmark || !bookmark.query) {
return;
} }
const parsed = JSON.parse(raw); const key = bookmark.query.toLowerCase();
if (!Array.isArray(parsed)) { if (seen.has(key)) {
return []; return;
} }
return parsed.map(normalizeCustomBookmark).filter(Boolean); seen.add(key);
} catch (error) { deduped.push(bookmark);
console.warn('Konnte Bookmarks nicht laden:', error); });
return [];
} return deduped;
} }
function saveCustomBookmarks(bookmarks) { function sortBookmarksByRecency(list) {
try { return [...list].sort((a, b) => {
const sanitized = Array.isArray(bookmarks) const aClick = a.last_clicked_at ? new Date(a.last_clicked_at).getTime() : -Infinity;
? bookmarks.map(normalizeCustomBookmark).filter(Boolean) const bClick = b.last_clicked_at ? new Date(b.last_clicked_at).getTime() : -Infinity;
: []; if (aClick !== bClick) {
localStorage.setItem(BOOKMARKS_STORAGE_KEY, JSON.stringify(sanitized)); return bClick - aClick;
} catch (error) { }
console.warn('Konnte Bookmarks nicht speichern:', error);
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) { function formatFacebookDateParts(date) {
@@ -457,6 +604,22 @@ function openBookmark(bookmark) {
queries.push(''); 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) => { queries.forEach((searchTerm) => {
const url = buildBookmarkSearchUrl(searchTerm); const url = buildBookmarkSearchUrl(searchTerm);
if (url) { if (url) {
@@ -465,18 +628,114 @@ function openBookmark(bookmark) {
}); });
} }
function removeBookmark(bookmarkId) { async function markBookmarkClick(bookmarkId) {
if (!bookmarkId) { if (!bookmarkId) {
return; return;
} }
const current = loadCustomBookmarks(); try {
const next = current.filter((bookmark) => bookmark.id !== bookmarkId); const response = await apiFetch(`${API_URL}/bookmarks/${encodeURIComponent(bookmarkId)}/click`, {
if (next.length === current.length) { 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; 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() { function renderBookmarks() {
@@ -486,62 +745,97 @@ function renderBookmarks() {
bookmarksList.innerHTML = ''; bookmarksList.innerHTML = '';
const items = [...DEFAULT_BOOKMARKS, ...loadCustomBookmarks()]; if (bookmarkState.loading && !bookmarkState.loaded) {
const loading = document.createElement('div');
const staticDefault = { loading.className = 'bookmark-status bookmark-status--loading';
id: 'default-empty', loading.textContent = 'Lade Bookmarks...';
label: 'Gewinnspiel / gewinnen / verlosen', bookmarksList.appendChild(loading);
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);
return; return;
} }
items.forEach((bookmark) => { if (bookmarkState.error && !bookmarkState.loaded) {
const item = document.createElement('div'); const errorNode = document.createElement('div');
item.className = 'bookmark-item'; errorNode.className = 'bookmark-status bookmark-status--error';
item.setAttribute('role', 'listitem'); errorNode.textContent = bookmarkState.error;
bookmarksList.appendChild(errorNode);
return;
}
const button = document.createElement('button'); if (bookmarkState.error && bookmarkState.loaded) {
button.type = 'button'; const warnNode = document.createElement('div');
button.className = 'bookmark-button'; warnNode.className = 'bookmark-status bookmark-status--error';
const label = bookmark.label || bookmark.query; warnNode.textContent = bookmarkState.error;
button.textContent = label; bookmarksList.appendChild(warnNode);
}
const searchVariants = buildBookmarkSearchQueries(bookmark.query); const dynamicBookmarks = bookmarkState.items;
if (searchVariants.length) {
button.title = searchVariants.map((variant) => `${variant}`).join('\n'); const staticDefault = {
} else { id: 'default-search',
button.title = `Suche nach "${bookmark.query}" (letzte 4 Wochen)`; 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 sections = [];
const removeBtn = document.createElement('button');
removeBtn.type = 'button'; if (recent.length) {
removeBtn.className = 'bookmark-remove-btn'; sections.push({
removeBtn.setAttribute('aria-label', `${bookmark.label || bookmark.query} entfernen`); id: 'recent',
removeBtn.textContent = '×'; title: 'Zuletzt verwendet',
removeBtn.addEventListener('click', (event) => { items: recent
event.preventDefault(); });
event.stopPropagation(); }
removeBookmark(bookmark.id);
}); sections.push({
item.appendChild(removeBtn); 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'); bookmarkPanelToggle.setAttribute('aria-expanded', bookmarkPanelVisible ? 'true' : 'false');
if (bookmarkPanelVisible) { if (bookmarkPanelVisible) {
renderBookmarks(); if (!bookmarkState.loaded && !bookmarkState.loading) {
bookmarkState.loading = true;
renderBookmarks();
refreshBookmarks().catch(() => {});
} else {
renderBookmarks();
}
resetBookmarkForm(); resetBookmarkForm();
if (bookmarkQueryInput) { if (bookmarkQueryInput) {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
@@ -619,13 +919,14 @@ function toggleBookmarkPanel(forceVisible) {
} else { } else {
resetBookmarkForm(); resetBookmarkForm();
removeBookmarkOutsideHandler(); removeBookmarkOutsideHandler();
bookmarkState.error = null;
if (bookmarkPanelToggle) { if (bookmarkPanelToggle) {
bookmarkPanelToggle.focus(); bookmarkPanelToggle.focus();
} }
} }
} }
function handleBookmarkSubmit(event) { async function handleBookmarkSubmit(event) {
event.preventDefault(); event.preventDefault();
if (!bookmarkForm) { if (!bookmarkForm) {
@@ -643,26 +944,44 @@ function handleBookmarkSubmit(event) {
return; return;
} }
const customBookmarks = loadCustomBookmarks(); bookmarkState.error = null;
const normalizedQuery = query.toLowerCase();
const existingIndex = customBookmarks.findIndex((bookmark) => bookmark.query.toLowerCase() === normalizedQuery);
const nextBookmark = { try {
id: existingIndex >= 0 ? customBookmarks[existingIndex].id : `custom-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, const response = await apiFetch(`${API_URL}/bookmarks`, {
label: name || query, method: 'POST',
query, headers: {
type: 'custom' 'Content-Type': 'application/json'
}; },
body: JSON.stringify({
label: name,
query
})
});
if (existingIndex >= 0) { if (response.status === 409) {
customBookmarks[existingIndex] = nextBookmark; bookmarkState.error = 'Bookmark existiert bereits.';
} else { renderBookmarks();
customBookmarks.push(nextBookmark); 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() { function initializeBookmarks() {
@@ -670,7 +989,7 @@ function initializeBookmarks() {
return; return;
} }
renderBookmarks(); refreshBookmarks().catch(() => {});
if (bookmarkPanel) { if (bookmarkPanel) {
bookmarkPanel.setAttribute('aria-hidden', 'true'); bookmarkPanel.setAttribute('aria-hidden', 'true');

View File

@@ -1109,13 +1109,16 @@ h1 {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);
right: 0; right: 0;
width: min(420px, 90vw); width: min(540px, 92vw);
max-height: 70vh;
background: #ffffff; background: #ffffff;
border-radius: 10px; border-radius: 12px;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18); box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18);
padding: 16px; padding: 16px;
z-index: 20; z-index: 20;
border: 1px solid rgba(229, 231, 235, 0.8); border: 1px solid rgba(229, 231, 235, 0.8);
display: flex;
flex-direction: column;
} }
.bookmark-panel__header { .bookmark-panel__header {
@@ -1124,11 +1127,13 @@ h1 {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
border-bottom: 1px solid rgba(229, 231, 235, 0.8);
padding-bottom: 8px;
} }
.bookmark-panel__title { .bookmark-panel__title {
margin: 0; margin: 0;
font-size: 16px; font-size: 15px;
font-weight: 600; font-weight: 600;
} }
@@ -1148,62 +1153,136 @@ h1 {
} }
.bookmark-list { .bookmark-list {
flex: 1;
overflow-y: auto;
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 16px;
padding-right: 6px;
}
.bookmark-section {
display: flex;
flex-direction: column;
gap: 8px; gap: 8px;
} }
.bookmark-item { .bookmark-section__header {
display: inline-flex; display: flex;
align-items: center; justify-content: space-between;
gap: 6px; align-items: baseline;
padding: 6px 10px; gap: 12px;
background: #f0f2f5;
border-radius: 999px;
} }
.bookmark-button { .bookmark-section__title {
border: none; margin: 0;
background: transparent; font-size: 13px;
color: #1d2129;
font-weight: 600; font-weight: 600;
cursor: pointer; color: #111827;
display: inline-flex; }
align-items: center;
.bookmark-section__hint {
font-size: 11px;
color: #6b7280;
}
.bookmark-section__list {
display: flex;
flex-direction: column;
gap: 6px; gap: 6px;
padding: 0;
font-size: 14px;
} }
.bookmark-button:hover, .bookmark-row {
.bookmark-button:focus { display: grid;
text-decoration: underline; 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 { .bookmark-row[data-state="never-used"] {
outline: 2px solid #2563eb; background: #eef2ff;
outline-offset: 2px;
} }
.bookmark-remove-btn { .bookmark-row__open {
border: none; border: none;
background: transparent; 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; cursor: pointer;
font-size: 16px; font-size: 16px;
line-height: 1; 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-row__remove:hover,
.bookmark-remove-btn:focus { .bookmark-row__remove:focus-visible {
color: #c0392b; 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 { .bookmark-form {
margin-top: 12px; margin-top: 16px;
border-top: 1px solid #e4e6eb; border-top: 1px solid #e5e7eb;
padding-top: 12px; padding-top: 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
@@ -1246,11 +1325,27 @@ h1 {
} }
.bookmark-empty { .bookmark-empty {
font-size: 14px; font-size: 13px;
color: #65676b; color: #4b5563;
background: #f0f2f5; background: #f3f4f6;
border-radius: 8px; border-radius: 10px;
padding: 12px; 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 { .screenshot-modal {
@@ -1434,6 +1529,12 @@ h1 {
position: static; position: static;
width: 100%; width: 100%;
margin-top: 10px; 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 { .bookmark-form__actions {