diff --git a/README.md b/README.md index 65a715e..b119737 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,13 @@ Das Web-Interface ist erreichbar unter `http://localhost:8080` - Den Beitrag in neuem Tab zu öffnen - Automatisch für dein Profil abzuhaken +### Daily-Bookmarks-Seite + +- Öffne `http://localhost:8080/daily-bookmarks.html` für eine eigenständige Bookmark-Übersicht +- Lege Links mit dynamischen Platzhaltern an (z. B. `{{day}}`, `{{date-1}}`) +- Markiere Bookmarks einmal pro Tag als erledigt; der Status wird in der SQLite-DB gespeichert +- Links werden pro gewähltem Tag aufgelöst, z. B. `https://www.test.de/tag-{{day}}/` + ## API-Endpunkte Das Backend stellt folgende REST-API bereit: @@ -188,4 +195,4 @@ npm run dev ## Lizenz -MIT \ No newline at end of file +MIT diff --git a/backend/server.js b/backend/server.js index c71927b..a2a890f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -26,6 +26,10 @@ 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 DAILY_BOOKMARK_TITLE_MAX_LENGTH = 160; +const DAILY_BOOKMARK_URL_MAX_LENGTH = 800; +const DAILY_BOOKMARK_NOTES_MAX_LENGTH = 800; +const DAILY_BOOKMARK_MARKER_MAX_LENGTH = 120; const SPORTS_SCORING_DEFAULTS = { enabled: 1, threshold: 5, @@ -485,6 +489,222 @@ function serializeBookmark(row) { }; } +function formatDayKeyFromDate(date = new Date()) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function normalizeDayKeyValue(rawValue) { + if (!rawValue && rawValue !== 0) { + return null; + } + + if (rawValue instanceof Date) { + if (!Number.isNaN(rawValue.getTime())) { + return formatDayKeyFromDate(rawValue); + } + return null; + } + + if (typeof rawValue !== 'string' && typeof rawValue !== 'number') { + return null; + } + + const stringValue = String(rawValue).trim(); + if (!stringValue) { + return null; + } + + const match = stringValue.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); + if (match) { + const year = parseInt(match[1], 10); + const month = parseInt(match[2], 10); + const day = parseInt(match[3], 10); + const parsed = new Date(year, month - 1, day); + if ( + !Number.isNaN(parsed.getTime()) + && parsed.getFullYear() === year + && parsed.getMonth() === month - 1 + && parsed.getDate() === day + ) { + return formatDayKeyFromDate(parsed); + } + } + + const parsed = new Date(stringValue); + if (!Number.isNaN(parsed.getTime())) { + return formatDayKeyFromDate(parsed); + } + + return null; +} + +function resolveDayKey(dayKey, { defaultToToday = true } = {}) { + const normalized = normalizeDayKeyValue(dayKey); + if (normalized) { + return normalized; + } + return defaultToToday ? formatDayKeyFromDate() : null; +} + +function dayKeyToDate(dayKey) { + const normalized = resolveDayKey(dayKey); + const match = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return new Date(); + } + const year = parseInt(match[1], 10); + const month = parseInt(match[2], 10); + const day = parseInt(match[3], 10); + return new Date(year, month - 1, day); +} + +function addDays(date, offsetDays = 0) { + const base = date instanceof Date ? date : new Date(); + const result = new Date(base); + result.setDate(result.getDate() + offsetDays); + return result; +} + +const DAILY_PLACEHOLDER_PATTERN = /\{\{\s*([^}]+)\s*\}\}/gi; + +function resolveDynamicUrlTemplate(template, dayKey) { + if (typeof template !== 'string') { + return ''; + } + + const baseDayKey = resolveDayKey(dayKey); + const baseDate = dayKeyToDate(baseDayKey); + + return template.replace(DAILY_PLACEHOLDER_PATTERN, (_match, contentRaw) => { + const content = (contentRaw || '').trim(); + if (!content) { + return ''; + } + + const counterMatch = content.match(/^counter:\s*([+-]?\d+)([+-]\d+)?$/i); + if (counterMatch) { + const base = parseInt(counterMatch[1], 10); + const offset = counterMatch[2] ? parseInt(counterMatch[2], 10) || 0 : 0; + if (Number.isNaN(base)) { + return ''; + } + const date = addDays(baseDate, offset); + return String(base + date.getDate()); + } + + const placeholderMatch = content.match(/^(date|day|dd|mm|month|yyyy|yy)([+-]\d+)?$/i); + if (!placeholderMatch) { + return content; + } + + const token = String(placeholderMatch[1] || '').toLowerCase(); + const offset = placeholderMatch[2] ? parseInt(placeholderMatch[2], 10) || 0 : 0; + const date = addDays(baseDate, offset); + + switch (token) { + case 'date': + return formatDayKeyFromDate(date); + case 'day': + return String(date.getDate()); + case 'dd': + return String(date.getDate()).padStart(2, '0'); + case 'month': + case 'mm': + return String(date.getMonth() + 1).padStart(2, '0'); + case 'yyyy': + return String(date.getFullYear()); + case 'yy': + return String(date.getFullYear()).slice(-2); + default: + return token; + } + }); +} + +function normalizeDailyBookmarkTitle(value, fallback = '') { + const source = typeof value === 'string' ? value : fallback; + let title = (source || '').trim(); + if (!title && fallback) { + title = String(fallback || '').trim(); + } + if (!title) { + title = 'Bookmark'; + } + title = title.replace(/\s+/g, ' '); + if (title.length > DAILY_BOOKMARK_TITLE_MAX_LENGTH) { + title = title.slice(0, DAILY_BOOKMARK_TITLE_MAX_LENGTH); + } + return title; +} + +function normalizeDailyBookmarkUrlTemplate(value) { + if (typeof value !== 'string') { + return null; + } + + let template = value.trim(); + if (!template) { + return null; + } + + if (template.length > DAILY_BOOKMARK_URL_MAX_LENGTH) { + template = template.slice(0, DAILY_BOOKMARK_URL_MAX_LENGTH); + } + + return template; +} + +function normalizeDailyBookmarkNotes(value) { + if (typeof value !== 'string') { + return ''; + } + + let notes = value.trim(); + if (notes.length > DAILY_BOOKMARK_NOTES_MAX_LENGTH) { + notes = notes.slice(0, DAILY_BOOKMARK_NOTES_MAX_LENGTH); + } + return notes; +} + +function normalizeDailyBookmarkMarker(value) { + if (typeof value !== 'string') { + return ''; + } + + let marker = value.trim(); + marker = marker.replace(/\s+/g, ' '); + if (marker.length > DAILY_BOOKMARK_MARKER_MAX_LENGTH) { + marker = marker.slice(0, DAILY_BOOKMARK_MARKER_MAX_LENGTH); + } + return marker; +} + +function serializeDailyBookmark(row, dayKey) { + if (!row) { + return null; + } + + const resolvedDayKey = resolveDayKey(dayKey); + const resolvedUrl = resolveDynamicUrlTemplate(row.url_template, resolvedDayKey); + + return { + id: row.id, + title: row.title, + url_template: row.url_template, + marker: row.marker || '', + resolved_url: resolvedUrl, + notes: row.notes || '', + created_at: sqliteTimestampToUTC(row.created_at), + updated_at: sqliteTimestampToUTC(row.updated_at), + last_completed_at: sqliteTimestampToUTC(row.last_completed_at), + completed_for_day: !!row.completed_for_day, + day_key: resolvedDayKey + }; +} + function normalizeFacebookPostUrl(rawValue) { if (typeof rawValue !== 'string') { return null; @@ -927,6 +1147,136 @@ const updateBookmarkLastClickedStmt = db.prepare(` WHERE id = ? `); +db.exec(` + CREATE TABLE IF NOT EXISTS daily_bookmarks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + url_template TEXT NOT NULL, + notes TEXT, + marker TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS daily_bookmark_checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bookmark_id TEXT NOT NULL, + day_key TEXT NOT NULL, + completed_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (bookmark_id) REFERENCES daily_bookmarks(id) ON DELETE CASCADE, + UNIQUE(bookmark_id, day_key) + ); +`); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_daily_bookmark_checks_day + ON daily_bookmark_checks(day_key); +`); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_daily_bookmarks_updated + ON daily_bookmarks(updated_at); +`); + +ensureColumn('daily_bookmarks', 'marker', 'marker TEXT DEFAULT \'\''); + +const listDailyBookmarksStmt = db.prepare(` + SELECT + b.id, + b.title, + b.url_template, + b.notes, + b.marker, + b.created_at, + b.updated_at, + ( + SELECT MAX(completed_at) + FROM daily_bookmark_checks c + WHERE c.bookmark_id = b.id + ) AS last_completed_at, + EXISTS( + SELECT 1 + FROM daily_bookmark_checks c + WHERE c.bookmark_id = b.id + AND c.day_key = @dayKey + ) AS completed_for_day + FROM daily_bookmarks b + ORDER BY datetime(b.updated_at) DESC, datetime(b.created_at) DESC, b.title COLLATE NOCASE +`); + +const getDailyBookmarkStmt = db.prepare(` + SELECT + b.id, + b.title, + b.url_template, + b.notes, + b.marker, + b.created_at, + b.updated_at, + ( + SELECT MAX(completed_at) + FROM daily_bookmark_checks c + WHERE c.bookmark_id = b.id + ) AS last_completed_at, + EXISTS( + SELECT 1 + FROM daily_bookmark_checks c + WHERE c.bookmark_id = b.id + AND c.day_key = @dayKey + ) AS completed_for_day + FROM daily_bookmarks b + WHERE b.id = @bookmarkId +`); + +const insertDailyBookmarkStmt = db.prepare(` + INSERT INTO daily_bookmarks (id, title, url_template, notes, marker) + VALUES (@id, @title, @url_template, @notes, @marker) +`); + +const findDailyBookmarkByUrlStmt = db.prepare(` + SELECT id + FROM daily_bookmarks + WHERE LOWER(url_template) = LOWER(?) + LIMIT 1 +`); + +const findOtherDailyBookmarkByUrlStmt = db.prepare(` + SELECT id + FROM daily_bookmarks + WHERE LOWER(url_template) = LOWER(@url) + AND id <> @id + LIMIT 1 +`); + +const updateDailyBookmarkStmt = db.prepare(` + UPDATE daily_bookmarks + SET title = @title, + url_template = @url_template, + notes = @notes, + marker = @marker, + updated_at = CURRENT_TIMESTAMP + WHERE id = @id +`); + +const deleteDailyBookmarkStmt = db.prepare(` + DELETE FROM daily_bookmarks + WHERE id = ? +`); + +const upsertDailyBookmarkCheckStmt = db.prepare(` + INSERT INTO daily_bookmark_checks (bookmark_id, day_key) + VALUES (@bookmarkId, @dayKey) + ON CONFLICT(bookmark_id, day_key) DO NOTHING +`); + +const deleteDailyBookmarkCheckStmt = db.prepare(` + DELETE FROM daily_bookmark_checks + WHERE bookmark_id = @bookmarkId + AND day_key = @dayKey +`); + 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'); @@ -1936,8 +2286,13 @@ const selectAlternateUrlsForPostStmt = db.prepare(` ORDER BY created_at ASC `); const selectPostByTextHashStmt = db.prepare('SELECT * FROM posts WHERE post_text_hash = ?'); +const selectChecksForPostStmt = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?'); +const updateCheckPostStmt = db.prepare('UPDATE checks SET post_id = ? WHERE id = ?'); +const updateCheckTimestampStmt = db.prepare('UPDATE checks SET checked_at = ? WHERE id = ?'); +const deleteCheckByIdStmt = db.prepare('DELETE FROM checks WHERE id = ?'); -function storePostUrls(postId, primaryUrl, additionalUrls = []) { +function storePostUrls(postId, primaryUrl, additionalUrls = [], options = {}) { + const { skipContentKeyCheck = false } = options; if (!postId || !primaryUrl) { return; } @@ -1956,16 +2311,18 @@ function storePostUrls(postId, primaryUrl, additionalUrls = []) { continue; } - const candidateKey = extractFacebookContentKey(normalized); - if (!candidateKey || candidateKey !== primaryKey) { - continue; + if (!skipContentKeyCheck) { + const candidateKey = extractFacebookContentKey(normalized); + if (!candidateKey || candidateKey !== primaryKey) { + continue; + } } const existingPostId = findPostIdByUrl(normalized); if (existingPostId && existingPostId !== postId) { continue; } - insertPostUrlStmt.run(postId, normalized, 0); + insertPostUrlStmt.run(postId, normalized); } } } @@ -2289,6 +2646,276 @@ app.delete('/api/bookmarks/:bookmarkId', (req, res) => { } }); +app.get('/api/daily-bookmarks', (req, res) => { + const dayKey = resolveDayKey(req.query && req.query.day); + + try { + const rows = listDailyBookmarksStmt.all({ dayKey }); + res.json(rows.map((row) => serializeDailyBookmark(row, dayKey))); + } catch (error) { + console.error('Failed to load daily bookmarks:', error); + res.status(500).json({ error: 'Daily Bookmarks konnten nicht geladen werden' }); + } +}); + +app.post('/api/daily-bookmarks', (req, res) => { + const payload = req.body || {}; + const rawDay = (payload && payload.day) || (req.query && req.query.day); + const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay); + if (!dayKey) { + return res.status(400).json({ error: 'Ungültiges Tagesformat' }); + } + const normalizedUrl = normalizeDailyBookmarkUrlTemplate(payload.url_template || payload.url); + const normalizedTitle = normalizeDailyBookmarkTitle(payload.title || payload.label, normalizedUrl); + const normalizedNotes = normalizeDailyBookmarkNotes(payload.notes); + const normalizedMarker = normalizeDailyBookmarkMarker(payload.marker || payload.tag); + + if (!normalizedUrl) { + return res.status(400).json({ error: 'URL-Template ist erforderlich' }); + } + + if (findDailyBookmarkByUrlStmt.get(normalizedUrl)) { + return res.status(409).json({ error: 'Bookmark mit dieser URL existiert bereits' }); + } + + try { + const id = uuidv4(); + insertDailyBookmarkStmt.run({ + id, + title: normalizedTitle, + url_template: normalizedUrl, + notes: normalizedNotes, + marker: normalizedMarker + }); + upsertDailyBookmarkCheckStmt.run({ bookmarkId: id, dayKey }); + const saved = getDailyBookmarkStmt.get({ bookmarkId: id, dayKey }); + res.status(201).json(serializeDailyBookmark(saved, dayKey)); + } catch (error) { + console.error('Failed to create daily bookmark:', error); + res.status(500).json({ error: 'Daily Bookmark konnte nicht erstellt werden' }); + } +}); + +app.post('/api/daily-bookmarks/import', (req, res) => { + const payload = req.body || {}; + const rawDay = (payload && payload.day) || (req.query && req.query.day); + const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay); + if (!dayKey) { + return res.status(400).json({ error: 'Ungültiges Tagesformat' }); + } + + const normalizedMarker = normalizeDailyBookmarkMarker(payload.marker || payload.tag); + const collected = []; + const addValue = (value) => { + if (typeof value !== 'string') { + return; + } + const trimmed = value.trim(); + if (trimmed) { + collected.push(trimmed); + } + }; + + if (Array.isArray(payload.urls)) { + payload.urls.forEach(addValue); + } + if (Array.isArray(payload.url_templates)) { + payload.url_templates.forEach(addValue); + } + if (typeof payload.text === 'string') { + payload.text.split(/\r?\n|,/).forEach(addValue); + } + if (typeof payload.urls_text === 'string') { + payload.urls_text.split(/\r?\n|,/).forEach(addValue); + } + + const inputCount = collected.length; + if (!inputCount) { + return res.status(400).json({ error: 'Keine URLs zum Import übergeben' }); + } + + const normalizedTemplates = collected + .map((raw) => normalizeDailyBookmarkUrlTemplate(raw)) + .filter(Boolean); + const invalidCount = inputCount - normalizedTemplates.length; + const uniqueTemplates = [...new Set(normalizedTemplates)]; + const duplicateCount = normalizedTemplates.length - uniqueTemplates.length; + + const createdItems = []; + let skippedExisting = 0; + + const insertMany = db.transaction((templates) => { + for (const template of templates) { + if (findDailyBookmarkByUrlStmt.get(template)) { + skippedExisting += 1; + continue; + } + const id = uuidv4(); + const title = normalizeDailyBookmarkTitle('', template); + insertDailyBookmarkStmt.run({ + id, + title, + url_template: template, + notes: '', + marker: normalizedMarker + }); + upsertDailyBookmarkCheckStmt.run({ bookmarkId: id, dayKey }); + const saved = getDailyBookmarkStmt.get({ bookmarkId: id, dayKey }); + if (saved) { + createdItems.push(saved); + } + } + }); + + try { + insertMany(uniqueTemplates); + res.json({ + created: createdItems.length, + skipped_existing: skippedExisting, + skipped_invalid: invalidCount, + skipped_duplicates: duplicateCount, + marker: normalizedMarker, + day_key: dayKey, + items: createdItems.map((row) => serializeDailyBookmark(row, dayKey)) + }); + } catch (error) { + console.error('Failed to import daily bookmarks:', error); + res.status(500).json({ error: 'Import fehlgeschlagen' }); + } +}); + +app.put('/api/daily-bookmarks/:bookmarkId', (req, res) => { + const { bookmarkId } = req.params; + if (!bookmarkId) { + return res.status(400).json({ error: 'Bookmark-ID fehlt' }); + } + + const payload = req.body || {}; + const rawDay = (payload && payload.day) || (req.query && req.query.day); + const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay); + if (!dayKey) { + return res.status(400).json({ error: 'Ungültiges Tagesformat' }); + } + + const existing = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); + if (!existing) { + return res.status(404).json({ error: 'Bookmark nicht gefunden' }); + } + + const normalizedUrl = normalizeDailyBookmarkUrlTemplate( + payload.url_template ?? payload.url ?? existing.url_template + ); + const normalizedTitle = normalizeDailyBookmarkTitle( + payload.title ?? payload.label ?? existing.title, + normalizedUrl || existing.url_template + ); + const normalizedNotes = normalizeDailyBookmarkNotes( + payload.notes ?? existing.notes ?? '' + ); + const normalizedMarker = normalizeDailyBookmarkMarker( + payload.marker ?? existing.marker ?? '' + ); + + if (!normalizedUrl) { + return res.status(400).json({ error: 'URL-Template ist erforderlich' }); + } + + const otherWithUrl = findOtherDailyBookmarkByUrlStmt.get({ + url: normalizedUrl, + id: bookmarkId + }); + if (otherWithUrl) { + return res.status(409).json({ error: 'Bookmark mit dieser URL existiert bereits' }); + } + + try { + updateDailyBookmarkStmt.run({ + id: bookmarkId, + title: normalizedTitle, + url_template: normalizedUrl, + notes: normalizedNotes, + marker: normalizedMarker + }); + const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); + res.json(serializeDailyBookmark(updated, dayKey)); + } catch (error) { + console.error('Failed to update daily bookmark:', error); + res.status(500).json({ error: 'Daily Bookmark konnte nicht aktualisiert werden' }); + } +}); + +app.delete('/api/daily-bookmarks/:bookmarkId', (req, res) => { + const { bookmarkId } = req.params; + if (!bookmarkId) { + return res.status(400).json({ error: 'Bookmark-ID fehlt' }); + } + + try { + const result = deleteDailyBookmarkStmt.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 daily bookmark:', error); + res.status(500).json({ error: 'Daily Bookmark konnte nicht gelöscht werden' }); + } +}); + +app.post('/api/daily-bookmarks/:bookmarkId/check', (req, res) => { + const { bookmarkId } = req.params; + if (!bookmarkId) { + return res.status(400).json({ error: 'Bookmark-ID fehlt' }); + } + + const rawDay = (req.body && req.body.day) || (req.query && req.query.day); + const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay); + if (!dayKey) { + return res.status(400).json({ error: 'Ungültiges Tagesformat' }); + } + + try { + const existing = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); + if (!existing) { + return res.status(404).json({ error: 'Bookmark nicht gefunden' }); + } + + upsertDailyBookmarkCheckStmt.run({ bookmarkId, dayKey }); + const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); + res.json(serializeDailyBookmark(updated, dayKey)); + } catch (error) { + console.error('Failed to complete daily bookmark:', error); + res.status(500).json({ error: 'Daily Bookmark konnte nicht abgehakt werden' }); + } +}); + +app.delete('/api/daily-bookmarks/:bookmarkId/check', (req, res) => { + const { bookmarkId } = req.params; + if (!bookmarkId) { + return res.status(400).json({ error: 'Bookmark-ID fehlt' }); + } + + const rawDay = (req.body && req.body.day) || (req.query && req.query.day); + const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay); + if (!dayKey) { + return res.status(400).json({ error: 'Ungültiges Tagesformat' }); + } + + try { + const existing = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); + if (!existing) { + return res.status(404).json({ error: 'Bookmark nicht gefunden' }); + } + + deleteDailyBookmarkCheckStmt.run({ bookmarkId, dayKey }); + const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); + res.json(serializeDailyBookmark(updated, dayKey)); + } catch (error) { + console.error('Failed to undo daily bookmark completion:', error); + res.status(500).json({ error: 'Daily Bookmark konnte nicht zurückgesetzt werden' }); + } +}); + // Get all posts app.get('/api/events', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); @@ -3188,6 +3815,183 @@ app.patch('/api/posts/:postId', (req, res) => { } }); +app.post('/api/posts/merge', (req, res) => { + const { primary_post_id, secondary_post_id, primary_url } = req.body || {}; + + if (!primary_post_id || !secondary_post_id) { + return res.status(400).json({ error: 'primary_post_id und secondary_post_id sind erforderlich' }); + } + + if (primary_post_id === secondary_post_id) { + return res.status(400).json({ error: 'Die Post-IDs müssen unterschiedlich sein' }); + } + + try { + const primaryPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(primary_post_id); + const secondaryPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(secondary_post_id); + + if (!primaryPost || !secondaryPost) { + return res.status(404).json({ error: 'Einer der Beiträge wurde nicht gefunden' }); + } + + const normalizedPrimaryUrl = primary_url + ? normalizeFacebookPostUrl(primary_url) + : normalizeFacebookPostUrl(primaryPost.url); + + if (!normalizedPrimaryUrl) { + return res.status(400).json({ error: 'primary_url ist ungültig' }); + } + + const conflictId = findPostIdByUrl(normalizedPrimaryUrl); + if (conflictId && conflictId !== primary_post_id && conflictId !== secondary_post_id) { + return res.status(409).json({ error: 'Die gewählte Haupt-URL gehört bereits zu einem anderen Beitrag' }); + } + + const collectUrlsForPost = (post) => { + const urls = []; + if (post && post.url) { + urls.push(post.url); + } + if (post && post.id) { + const alternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url); + urls.push(...alternates); + } + return urls; + }; + + const mergedUrlSet = new Set(); + for (const url of [...collectUrlsForPost(primaryPost), ...collectUrlsForPost(secondaryPost)]) { + const normalized = normalizeFacebookPostUrl(url); + if (normalized) { + mergedUrlSet.add(normalized); + } + } + mergedUrlSet.add(normalizedPrimaryUrl); + + const alternateUrls = Array.from(mergedUrlSet).filter(url => url !== normalizedPrimaryUrl); + + const mergedDeadline = (() => { + if (primaryPost.deadline_at && secondaryPost.deadline_at) { + return new Date(primaryPost.deadline_at) <= new Date(secondaryPost.deadline_at) + ? primaryPost.deadline_at + : secondaryPost.deadline_at; + } + return primaryPost.deadline_at || secondaryPost.deadline_at || null; + })(); + + const mergedTargetCount = Math.max( + validateTargetCount(primaryPost.target_count) || 1, + validateTargetCount(secondaryPost.target_count) || 1 + ); + + const mergedPostText = (primaryPost.post_text && primaryPost.post_text.trim()) + ? primaryPost.post_text + : (secondaryPost.post_text || null); + const normalizedMergedPostText = mergedPostText ? normalizePostText(mergedPostText) : null; + const mergedPostTextHash = normalizedMergedPostText + ? computePostTextHash(normalizedMergedPostText) + : null; + + const mergedCreatorName = (primaryPost.created_by_name && primaryPost.created_by_name.trim()) + ? primaryPost.created_by_name + : (secondaryPost.created_by_name || null); + const mergedCreatorProfile = primaryPost.created_by_profile || secondaryPost.created_by_profile || null; + + const mergedTitle = (primaryPost.title && primaryPost.title.trim()) + ? primaryPost.title + : (secondaryPost.title || null); + + const mergeTransaction = db.transaction(() => { + // Move checks from secondary to primary (one per profile) + const primaryChecks = selectChecksForPostStmt.all(primary_post_id); + const secondaryChecks = selectChecksForPostStmt.all(secondary_post_id); + + const primaryByProfile = new Map(); + for (const check of primaryChecks) { + if (!check || !check.profile_number) { + continue; + } + const existing = primaryByProfile.get(check.profile_number); + if (!existing) { + primaryByProfile.set(check.profile_number, check); + } else { + if (new Date(check.checked_at) < new Date(existing.checked_at)) { + primaryByProfile.set(check.profile_number, check); + } else { + deleteCheckByIdStmt.run(check.id); + } + } + } + + for (const check of secondaryChecks) { + if (!check || !check.profile_number) { + continue; + } + const existing = primaryByProfile.get(check.profile_number); + if (!existing) { + updateCheckPostStmt.run(primary_post_id, check.id); + primaryByProfile.set(check.profile_number, check); + } else { + const existingDate = new Date(existing.checked_at); + const candidateDate = new Date(check.checked_at); + if (candidateDate < existingDate) { + updateCheckTimestampStmt.run(check.checked_at, existing.id); + } + deleteCheckByIdStmt.run(check.id); + } + } + + // Delete secondary post (post_urls are cascaded) + db.prepare('DELETE FROM posts WHERE id = ?').run(secondary_post_id); + + const primaryContentKey = extractFacebookContentKey(normalizedPrimaryUrl); + db.prepare(` + UPDATE posts + SET url = ?, content_key = ?, target_count = ?, created_by_name = ?, created_by_profile = ?, + deadline_at = ?, title = ?, post_text = ?, post_text_hash = ?, last_change = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + normalizedPrimaryUrl, + primaryContentKey || null, + mergedTargetCount, + mergedCreatorName, + mergedCreatorProfile, + mergedDeadline, + mergedTitle, + normalizedMergedPostText, + mergedPostTextHash, + primary_post_id + ); + + storePostUrls(primary_post_id, normalizedPrimaryUrl, alternateUrls, { skipContentKeyCheck: true }); + recalcCheckedCount(primary_post_id); + + const updated = db.prepare('SELECT * FROM posts WHERE id = ?').get(primary_post_id); + return updated; + }); + + const merged = mergeTransaction(); + if (secondaryPost && secondaryPost.screenshot_path) { + const secondaryFile = path.join(screenshotDir, secondaryPost.screenshot_path); + if (fs.existsSync(secondaryFile)) { + try { + fs.unlinkSync(secondaryFile); + } catch (cleanupError) { + console.warn('Konnte Screenshot des zusammengeführten Beitrags nicht entfernen:', cleanupError.message); + } + } + } + const mapped = mapPostRow(merged); + broadcastPostChange(mapped, { reason: 'merged' }); + broadcastPostDeletion(secondary_post_id, { reason: 'merged' }); + + res.json(mapped); + } catch (error) { + console.error('Merge failed:', error); + res.status(500).json({ error: 'Beiträge konnten nicht gemerged werden' }); + } +}); + // Delete post app.delete('/api/posts/:postId', (req, res) => { try { @@ -3526,8 +4330,16 @@ function sanitizeAIComment(text) { return ''; } + // Clean up AI output: drop hidden tags, replace dashes, normalize spacing. return text .replace(/[\s\S]*?<\/think>/gi, '') + .replace(/[-–—]+/g, (match, offset, full) => { + const prev = full[offset - 1]; + const next = full[offset + match.length]; + const prevIsWord = prev && /[A-Za-z0-9ÄÖÜäöüß]/.test(prev); + const nextIsWord = next && /[A-Za-z0-9ÄÖÜäöüß]/.test(next); + return prevIsWord && nextIsWord ? match : ', '; + }) .replace(/\s{2,}/g, ' ') .trim(); } diff --git a/extension/content.js b/extension/content.js index 85dc6fc..8cdbe61 100644 --- a/extension/content.js +++ b/extension/content.js @@ -3434,6 +3434,7 @@ let selectionAINoteButton = null; let selectionAIRaf = null; let selectionAIHideTimeout = null; let selectionAIEnabledCached = null; +let selectionAIContextElement = null; const clearSelectionAIHideTimeout = () => { if (selectionAIHideTimeout) { @@ -3447,6 +3448,7 @@ const hideSelectionAIButton = () => { if (selectionAIContainer) { selectionAIContainer.style.display = 'none'; } + selectionAIContextElement = null; if (selectionAIButton) { selectionAIButton.dataset.selectionText = ''; } @@ -3511,10 +3513,12 @@ const ensureSelectionAIButton = () => { showToast('Textauswahl aus Eingabefeldern kann nicht genutzt werden', 'error'); return; } + const anchorElement = anchorNode && anchorNode.nodeType === Node.TEXT_NODE ? anchorNode.parentElement : anchorNode; - const postContext = anchorElement ? ensurePrimaryPostElement(anchorElement) : null; + const postContext = selectionAIContextElement + || (anchorElement ? ensurePrimaryPostElement(anchorElement) : null); if (!postContext) { showToast('Keinen zugehörigen Beitrag gefunden', 'error'); return; @@ -3669,6 +3673,18 @@ const updateSelectionAIButton = async () => { return; } + const contextElement = (() => { + const containerNode = range.commonAncestorContainer || anchorNode; + if (!containerNode) { + return null; + } + const element = containerNode.nodeType === Node.TEXT_NODE + ? containerNode.parentElement + : containerNode; + return element ? ensurePrimaryPostElement(element) : null; + })(); + selectionAIContextElement = contextElement; + const container = ensureSelectionAIButton(); if (!selectionAIButton || !selectionAINoteButton) { hideSelectionAIButton(); @@ -4636,42 +4652,53 @@ function sanitizeAIComment(comment) { * Generate AI comment for a post */ async function generateAIComment(postText, profileNumber, options = {}) { - const { signal = null, preferredCredentialId = null } = options; - try { - const payload = { - postText, - profileNumber - }; + const { signal = null, preferredCredentialId = null, maxAttempts = 3 } = options; + const payload = { + postText, + profileNumber + }; - if (typeof preferredCredentialId === 'number') { - payload.preferredCredentialId = preferredCredentialId; - } - - const response = await backendFetch(`${API_URL}/ai/generate-comment`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to generate comment'); - } - - const data = await response.json(); - const sanitizedComment = sanitizeAIComment(data.comment); - - if (!sanitizedComment) { - throw new Error('AI-Antwort enthält keinen gültigen Text'); - } - - return sanitizedComment; - - } catch (error) { - console.error('[FB Tracker] AI comment generation failed:', error); - throw error; + if (typeof preferredCredentialId === 'number') { + payload.preferredCredentialId = preferredCredentialId; } + + let lastError = null; + const attempts = Math.max(1, maxAttempts); + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await backendFetch(`${API_URL}/ai/generate-comment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to generate comment'); + } + + const data = await response.json(); + const sanitizedComment = sanitizeAIComment(data.comment); + + if (sanitizedComment) { + return sanitizedComment; + } + + lastError = new Error('AI response empty'); + } catch (error) { + lastError = error; + } + + if (attempt < attempts) { + console.warn(`[FB Tracker] AI comment generation attempt ${attempt} failed, retrying...`, lastError); + await delay(200); + } + } + + console.error('[FB Tracker] AI comment generation failed after retries:', lastError); + throw new Error('AI-Antwort konnte nicht erzeugt werden. Bitte später erneut versuchen.'); } async function handleSelectionAIRequest(selectionText, sendResponse) { diff --git a/web/Dockerfile b/web/Dockerfile index a062301..756c9c5 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -5,12 +5,15 @@ COPY posts.html /usr/share/nginx/html/ COPY dashboard.html /usr/share/nginx/html/ COPY settings.html /usr/share/nginx/html/ COPY bookmarks.html /usr/share/nginx/html/ +COPY daily-bookmarks.html /usr/share/nginx/html/ COPY style.css /usr/share/nginx/html/ COPY dashboard.css /usr/share/nginx/html/ COPY settings.css /usr/share/nginx/html/ +COPY daily-bookmarks.css /usr/share/nginx/html/ COPY app.js /usr/share/nginx/html/ COPY dashboard.js /usr/share/nginx/html/ COPY settings.js /usr/share/nginx/html/ +COPY daily-bookmarks.js /usr/share/nginx/html/ COPY assets /usr/share/nginx/html/assets/ EXPOSE 80 diff --git a/web/app.js b/web/app.js index a2e8937..e969f18 100644 --- a/web/app.js +++ b/web/app.js @@ -20,6 +20,9 @@ let currentTab = 'pending'; let posts = []; let includeExpiredPosts = false; let profilePollTimer = null; +const MERGE_MAX_SELECTION = 2; +let mergeMode = false; +const mergeSelection = new Set(); const UPDATES_RECONNECT_DELAY = 5000; let updatesEventSource = null; let updatesReconnectTimer = null; @@ -232,11 +235,17 @@ const bookmarkForm = document.getElementById('bookmarkForm'); const bookmarkNameInput = document.getElementById('bookmarkName'); const bookmarkQueryInput = document.getElementById('bookmarkQuery'); const bookmarkCancelBtn = document.getElementById('bookmarkCancelBtn'); +const bookmarkQuickForm = document.getElementById('bookmarkQuickForm'); +const bookmarkQuickQueryInput = document.getElementById('bookmarkQuickQuery'); +const bookmarkQuickStatus = document.getElementById('bookmarkQuickStatus'); const bookmarkSearchInput = document.getElementById('bookmarkSearchInput'); const bookmarkSortSelect = document.getElementById('bookmarkSortSelect'); const bookmarkSortDirectionToggle = document.getElementById('bookmarkSortDirectionToggle'); const profileSelectElement = document.getElementById('profileSelect'); const includeExpiredToggle = document.getElementById('includeExpiredToggle'); +const mergeControls = document.getElementById('mergeControls'); +const mergeModeToggle = document.getElementById('mergeModeToggle'); +const mergeSubmitBtn = document.getElementById('mergeSubmitBtn'); const REFRESH_SETTINGS_KEY = 'trackerRefreshSettings'; const SORT_SETTINGS_KEY = 'trackerSortSettings'; @@ -294,6 +303,37 @@ function updateIncludeExpiredToggleVisibility() { wrapper.style.display = currentTab === 'all' ? 'inline-flex' : 'none'; } +function resetMergeSelection() { + mergeSelection.clear(); +} + +function updateMergeControlsUI() { + if (!mergeControls) { + return; + } + + const isAllTab = currentTab === 'all'; + mergeControls.hidden = !isAllTab; + mergeControls.style.display = isAllTab ? 'flex' : 'none'; + + if (!isAllTab && mergeMode) { + mergeMode = false; + resetMergeSelection(); + } + + if (mergeModeToggle) { + mergeModeToggle.disabled = !isAllTab; + mergeModeToggle.classList.toggle('active', mergeMode); + mergeModeToggle.textContent = mergeMode ? 'Merge-Modus: aktiv' : 'Merge-Modus'; + } + + if (mergeSubmitBtn) { + const count = mergeSelection.size; + mergeSubmitBtn.disabled = !mergeMode || !isAllTab || count !== MERGE_MAX_SELECTION; + mergeSubmitBtn.textContent = `Beiträge mergen (${count}/${MERGE_MAX_SELECTION})`; + } +} + function initializeFocusParams() { try { const params = new URLSearchParams(window.location.search); @@ -996,6 +1036,21 @@ function buildBookmarkSearchQueries(baseQuery) { return BOOKMARK_SUFFIXES.map((suffix) => `${trimmed} ${suffix}`.trim()); } +function openBookmarkQueries(baseQuery) { + const queries = Array.isArray(baseQuery) ? baseQuery : buildBookmarkSearchQueries(baseQuery); + let opened = 0; + + queries.forEach((searchTerm) => { + const url = buildBookmarkSearchUrl(searchTerm); + if (url) { + window.open(url, '_blank', 'noopener'); + opened += 1; + } + }); + + return opened; +} + function openBookmark(bookmark) { if (!bookmark) { return; @@ -1032,13 +1087,7 @@ function openBookmark(bookmark) { markBookmarkClick(stateBookmark.id); } - queries.forEach((searchTerm) => { - const url = buildBookmarkSearchUrl(searchTerm); - if (url) { - window.open(url, '_blank', 'noopener'); - } - }); - + openBookmarkQueries(queries); } async function markBookmarkClick(bookmarkId) { @@ -1380,6 +1429,45 @@ async function handleBookmarkSubmit(event) { } } +function setBookmarkQuickStatus(message, isError = false) { + if (!bookmarkQuickStatus) { + return; + } + + const hasMessage = typeof message === 'string' && message.trim(); + bookmarkQuickStatus.hidden = !hasMessage; + bookmarkQuickStatus.textContent = hasMessage ? message : ''; + bookmarkQuickStatus.classList.toggle('bookmark-status--error', !!isError); +} + +function handleBookmarkQuickSubmit(event) { + event.preventDefault(); + + if (!bookmarkQuickForm) { + return; + } + + const query = bookmarkQuickQueryInput ? bookmarkQuickQueryInput.value.trim() : ''; + if (!query) { + setBookmarkQuickStatus('Bitte gib einen Suchbegriff ein.', true); + if (bookmarkQuickQueryInput) { + bookmarkQuickQueryInput.focus(); + } + return; + } + + const opened = openBookmarkQueries(query); + if (opened > 0) { + setBookmarkQuickStatus(`Suche „${query}“ geöffnet (ohne Speicherung).`); + if (bookmarkQuickQueryInput) { + bookmarkQuickQueryInput.value = ''; + bookmarkQuickQueryInput.focus(); + } + } else { + setBookmarkQuickStatus('Konnte die Suche nicht öffnen.', true); + } +} + function initializeBookmarks() { if (!bookmarksList) { return; @@ -1418,7 +1506,17 @@ function initializeBookmarks() { bookmarkForm.addEventListener('submit', handleBookmarkSubmit); } -if (bookmarkSearchInput) { + if (bookmarkQuickForm) { + bookmarkQuickForm.addEventListener('submit', handleBookmarkQuickSubmit); + } + + if (bookmarkQuickQueryInput) { + bookmarkQuickQueryInput.addEventListener('input', () => { + setBookmarkQuickStatus(''); + }); + } + + if (bookmarkSearchInput) { bookmarkSearchInput.value = bookmarkSearchTerm; bookmarkSearchInput.addEventListener('input', () => { bookmarkSearchTerm = typeof bookmarkSearchInput.value === 'string' @@ -1665,8 +1763,13 @@ function setTab(tab, { updateUrl = true } = {}) { } else { currentTab = 'pending'; } + if (currentTab !== 'all' && mergeMode) { + mergeMode = false; + resetMergeSelection(); + } updateTabButtons(); loadSortMode({ fromTabChange: true }); + updateMergeControlsUI(); if (updateUrl) { updateTabInUrl(); } @@ -2982,6 +3085,27 @@ if (searchInput) { }); } +if (mergeModeToggle) { + mergeModeToggle.addEventListener('click', () => { + if (currentTab !== 'all') { + alert('Merge-Modus ist nur in „Alle Beiträge“ verfügbar.'); + return; + } + mergeMode = !mergeMode; + if (!mergeMode) { + resetMergeSelection(); + } + updateMergeControlsUI(); + renderPosts(); + }); +} + +if (mergeSubmitBtn) { + mergeSubmitBtn.addEventListener('click', () => { + mergeSelectedPosts(); + }); +} + if (sortModeSelect) { sortModeSelect.addEventListener('change', () => { const value = sortModeSelect.value; @@ -3195,6 +3319,104 @@ function highlightPostCard(post) { clearFocusParamsFromUrl(); } +function toggleMergeSelection(postId, checkboxEl = null) { + if (!mergeMode || currentTab !== 'all') { + resetMergeSelection(); + if (checkboxEl) { + checkboxEl.checked = false; + } + updateMergeControlsUI(); + return; + } + + if (mergeSelection.has(postId)) { + mergeSelection.delete(postId); + } else { + if (mergeSelection.size >= MERGE_MAX_SELECTION) { + alert(`Es können maximal ${MERGE_MAX_SELECTION} Beiträge ausgewählt werden.`); + if (checkboxEl) { + checkboxEl.checked = false; + } + updateMergeControlsUI(); + return; + } + mergeSelection.add(postId); + } + + updateMergeControlsUI(); +} + +async function mergeSelectedPosts() { + if (!mergeMode || currentTab !== 'all') { + alert('Mergen ist nur in „Alle Beiträge“ möglich.'); + return; + } + + if (mergeSelection.size !== MERGE_MAX_SELECTION) { + alert('Bitte genau zwei Beiträge auswählen.'); + return; + } + + const [primaryId, secondaryId] = Array.from(mergeSelection); + + const confirmed = window.confirm( + `Beitrag ${primaryId} als Haupt-URL behalten und Beitrag ${secondaryId} anhängen?` + ); + if (!confirmed) { + return; + } + + if (mergeSubmitBtn) { + mergeSubmitBtn.disabled = true; + mergeSubmitBtn.textContent = 'Mergen...'; + } + + try { + const response = await apiFetch(`${API_URL}/posts/merge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + primary_post_id: primaryId, + secondary_post_id: secondaryId + }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + const message = data && data.error ? data.error : 'Beiträge konnten nicht gemerged werden.'; + throw new Error(message); + } + + const mergedPost = await response.json(); + posts = posts + .filter((post) => { + if (post.id === secondaryId) { + return false; + } + if (post.id === primaryId) { + return false; + } + return true; + }); + posts.push(mergedPost); + sortPostsByCreatedAt(); + + mergeMode = false; + resetMergeSelection(); + updateMergeControlsUI(); + renderPosts(); + alert('Beiträge wurden gemerged.'); + } catch (error) { + console.error('Merge error:', error); + alert(error.message || 'Beiträge konnten nicht gemerged werden.'); + } finally { + if (mergeSubmitBtn) { + mergeSubmitBtn.disabled = false; + } + updateMergeControlsUI(); + } +} + // Render posts function renderPosts() { hideLoading(); @@ -3206,6 +3428,7 @@ function renderPosts() { } updateIncludeExpiredToggleVisibility(); + updateMergeControlsUI(); closeActiveDeadlinePicker(); updateTabButtons(); cleanupLoadMoreObserver(); @@ -3359,6 +3582,13 @@ function attachPostEventHandlers(post, status) { return; } + const mergeCheckbox = card.querySelector('.merge-checkbox'); + if (mergeCheckbox) { + mergeCheckbox.addEventListener('change', () => { + toggleMergeSelection(post.id, mergeCheckbox); + }); + } + const openBtn = card.querySelector('.btn-open'); if (openBtn) { openBtn.addEventListener('click', () => openPost(post.id)); @@ -3548,6 +3778,15 @@ function createPostCard(post, status, meta = {}) { const tabTotalCount = typeof meta.tabTotalCount === 'number' ? meta.tabTotalCount : posts.length; const searchActive = !!meta.searchActive; const indexBadge = displayIndex !== null ? `#${String(displayIndex).padStart(2, '0')}` : ''; + const mergeSelectable = mergeMode && currentTab === 'all'; + const mergeCheckboxHtml = mergeSelectable + ? ` + + ` + : ''; const profileRowsHtml = status.profileStatuses.map((profileStatus) => { const classes = ['profile-line', `profile-line--${profileStatus.status}`]; @@ -3690,6 +3929,7 @@ function createPostCard(post, status, meta = {}) {
+ ${mergeCheckboxHtml} ${indexBadge}
${escapeHtml(titleText)}
@@ -84,6 +85,12 @@ +
+
+
+ + +
+

Öffnet die drei Varianten ohne ein Bookmark anzulegen.

+ +
diff --git a/web/style.css b/web/style.css index 25425a7..4b53e00 100644 --- a/web/style.css +++ b/web/style.css @@ -452,6 +452,36 @@ h1 { justify-content: flex-end; } +.merge-controls { + display: flex; + flex-direction: column; + gap: 4px; + padding: 0; + border: none; + background: transparent; +} + +.merge-actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.merge-actions .btn { + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + line-height: 1.1; +} + +#mergeModeToggle.active { + background: #0f172a; + color: #fff; +} + .search-filter-toggle { display: inline-flex; align-items: center; @@ -631,6 +661,25 @@ h1 { flex: 1; } +.merge-select { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px dashed #cbd5e1; + border-radius: 10px; + background: #f8fafc; + color: #0f172a; + font-size: 12px; + flex-shrink: 0; +} + +.merge-select input { + width: 16px; + height: 16px; + accent-color: #0f172a; +} + .post-index { font-size: 12px; color: #6b7280; @@ -1519,6 +1568,48 @@ h1 { margin-bottom: 12px; } +.bookmark-quicksearch { + border: 1px solid #e5e7eb; + background: #f9fafb; + border-radius: 12px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.bookmark-quicksearch__fields { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; +} + +.bookmark-quicksearch__field { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 260px; +} + +.bookmark-quicksearch__field span { + font-size: 13px; + color: #65676b; +} + +.bookmark-quicksearch__field input { + border: 1px solid #d0d3d9; + border-radius: 8px; + padding: 8px 10px; + font-size: 14px; +} + +.bookmark-quicksearch__hint { + margin: 0; + font-size: 12px; + color: #4b5563; +} + .bookmark-panel__search { display: flex; flex-direction: column;