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

@@ -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');

View File

@@ -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 {