From bf25e0f70ecfc3e974b58a1db8e7f0027e80b338 Mon Sep 17 00:00:00 2001 From: Meik Date: Sat, 21 Feb 2026 13:03:58 +0100 Subject: [PATCH] Add draggable trade fair columns with persisted order and last-searched column --- web/index.html | 1 + web/style.css | 38 ++++- web/trade-fairs.js | 390 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 384 insertions(+), 45 deletions(-) diff --git a/web/index.html b/web/index.html index 4607937..fb544e7 100644 --- a/web/index.html +++ b/web/index.html @@ -1555,6 +1555,7 @@ + diff --git a/web/style.css b/web/style.css index 62992c9..f119db0 100644 --- a/web/style.css +++ b/web/style.css @@ -1883,6 +1883,7 @@ h1 { .bookmark-subpage__table th { background: #f3f4f6; white-space: nowrap; + position: relative; } .bookmark-subpage__sort { @@ -1905,6 +1906,32 @@ h1 { color: #0f4bb8; } +.bookmark-subpage__table th[draggable="true"] { + cursor: move; +} + +.bookmark-subpage__table th.is-dragging { + opacity: 0.55; +} + +.bookmark-subpage__table th.is-drop-before::before, +.bookmark-subpage__table th.is-drop-after::after { + content: ''; + position: absolute; + top: 3px; + bottom: 3px; + width: 2px; + background: #0f4bb8; +} + +.bookmark-subpage__table th.is-drop-before::before { + left: -1px; +} + +.bookmark-subpage__table th.is-drop-after::after { + right: -1px; +} + .bookmark-subpage__messe-link { border: none; background: transparent; @@ -1921,11 +1948,18 @@ h1 { color: #1d4ed8; } -.bookmark-subpage__table td:nth-child(4), -.bookmark-subpage__table td:nth-child(15) { +.bookmark-subpage__table th[data-column-key="thema"], +.bookmark-subpage__table td[data-column="thema"], +.bookmark-subpage__table th[data-column-key="notiz"], +.bookmark-subpage__table td[data-column="notiz"] { min-width: 320px; } +.bookmark-subpage__table th[data-column-key="zuletzt_gesucht_am"], +.bookmark-subpage__table td[data-column="zuletzt_gesucht_am"] { + min-width: 170px; +} + .bookmark-subpage__table a { color: #1d4ed8; } diff --git a/web/trade-fairs.js b/web/trade-fairs.js index 833e0c2..a920242 100644 --- a/web/trade-fairs.js +++ b/web/trade-fairs.js @@ -3,9 +3,48 @@ const searchInput = document.getElementById('tradeFairSearchInput'); const meta = document.getElementById('tradeFairMeta'); const sortButtons = Array.from(document.querySelectorAll('.bookmark-subpage__sort[data-trade-sort]')); + const table = tableBody ? tableBody.closest('table') : null; + const headerRow = table ? table.querySelector('thead tr') : null; const SORT_STATE_KEY = 'fb_trade_fairs_sort_v1'; + const COLUMN_ORDER_STATE_KEY = 'fb_trade_fairs_columns_v1'; + const LAST_OPEN_STATE_KEY = 'fb_trade_fairs_last_open_v1'; + const DEFAULT_COLUMN_ORDER = [ + 'tage_bis_start', + 'rang', + 'messe', + 'zuletzt_gesucht_am', + 'thema', + 'stadt', + 'bundesland', + 'termin_start', + 'termin_ende', + 'besucher', + 'besucher_jahr', + 'besucher_status', + 'ausstellungsflaeche_m2', + 'ticketpreis_we_eur', + 'ticketpreis_unterderwoche_eur', + 'notiz', + 'quelle_homepage' + ]; - if (!tableBody || !searchInput || !meta || !sortButtons.length) { + if (!tableBody || !searchInput || !meta || !sortButtons.length || !table || !headerRow) { + return; + } + + const headerCellsByKey = new Map(); + sortButtons.forEach((button) => { + const key = button.dataset.tradeSort; + const th = button.closest('th'); + if (!key || !th) { + return; + } + th.dataset.columnKey = key; + th.draggable = true; + headerCellsByKey.set(key, th); + }); + + if (headerCellsByKey.size !== DEFAULT_COLUMN_ORDER.length) { return; } @@ -526,6 +565,7 @@ tage_bis_start: 'number', rang: 'number', messe: 'text', + zuletzt_gesucht_am: 'date', thema: 'text', stadt: 'text', bundesland: 'text', @@ -544,6 +584,8 @@ let sortKey = 'rang'; let sortDirection = 'asc'; let searchTerm = ''; + let draggedColumnKey = null; + let lastOpenedByTradeFair = {}; function toNumber(value) { if (typeof value === 'number' && Number.isFinite(value)) { @@ -582,7 +624,8 @@ function normalizeRow(row) { return { ...row, - tage_bis_start: getDaysUntil(row.termin_start) + tage_bis_start: getDaysUntil(row.termin_start), + zuletzt_gesucht_am: lastOpenedByTradeFair[getTradeFairOpenKey(row)] || null }; } @@ -676,6 +719,145 @@ }; } + function normalizeColumnOrder(rawOrder) { + if (!Array.isArray(rawOrder) || rawOrder.length !== DEFAULT_COLUMN_ORDER.length) { + return null; + } + + const filtered = rawOrder.filter((key) => DEFAULT_COLUMN_ORDER.includes(key)); + if (filtered.length !== DEFAULT_COLUMN_ORDER.length) { + return null; + } + if (new Set(filtered).size !== DEFAULT_COLUMN_ORDER.length) { + return null; + } + + return filtered; + } + + function getColumnOrderFromHeader() { + const keys = Array.from(headerRow.children) + .map((th) => th.dataset.columnKey) + .filter((key) => DEFAULT_COLUMN_ORDER.includes(key)); + + if (keys.length !== DEFAULT_COLUMN_ORDER.length) { + return [...DEFAULT_COLUMN_ORDER]; + } + return keys; + } + + function applyColumnOrder(order) { + order.forEach((key) => { + const th = headerCellsByKey.get(key); + if (th) { + headerRow.appendChild(th); + } + }); + } + + function loadColumnOrder() { + try { + const raw = localStorage.getItem(COLUMN_ORDER_STATE_KEY); + if (!raw) { + return; + } + const parsed = JSON.parse(raw); + const normalized = normalizeColumnOrder(parsed); + if (!normalized) { + return; + } + applyColumnOrder(normalized); + } catch (_error) { + // ignore + } + } + + function persistColumnOrder() { + try { + localStorage.setItem(COLUMN_ORDER_STATE_KEY, JSON.stringify(getColumnOrderFromHeader())); + } catch (_error) { + // ignore + } + } + + function normalizeLastOpenedState(rawState) { + if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) { + return {}; + } + + const normalized = {}; + Object.entries(rawState).forEach(([key, value]) => { + if (typeof key !== 'string' || typeof value !== 'string') { + return; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return; + } + normalized[key] = parsed.toISOString(); + }); + return normalized; + } + + function loadLastOpenedState() { + try { + const raw = localStorage.getItem(LAST_OPEN_STATE_KEY); + if (!raw) { + return {}; + } + return normalizeLastOpenedState(JSON.parse(raw)); + } catch (_error) { + return {}; + } + } + + function persistLastOpenedState() { + try { + localStorage.setItem(LAST_OPEN_STATE_KEY, JSON.stringify(lastOpenedByTradeFair)); + } catch (_error) { + // ignore + } + } + + function getTradeFairOpenKey(row) { + const messe = String(row.messe || '').trim().toLocaleLowerCase('de-DE'); + const city = String(row.stadt || '').trim().toLocaleLowerCase('de-DE'); + return `${messe}|${city}`; + } + + function setTradeFairLastOpened(row) { + const key = getTradeFairOpenKey(row); + if (!key) { + return null; + } + const nowIso = new Date().toISOString(); + lastOpenedByTradeFair[key] = nowIso; + persistLastOpenedState(); + return nowIso; + } + + function formatDateTime(iso) { + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) { + return 'k.A.'; + } + return parsed.toLocaleString('de-DE'); + } + + function formatLastSearched(iso) { + if (!iso) { + return 'noch nie'; + } + return formatDateTime(iso); + } + + function getTradeFairHoverTitle(row) { + const key = getTradeFairOpenKey(row); + const lastOpened = key ? lastOpenedByTradeFair[key] : ''; + const suffix = lastOpened ? formatDateTime(lastOpened) : 'noch nie'; + return `Als Bookmark-Suche oeffnen (3 Tabs)\\nZuletzt geoeffnet: ${suffix}`; + } + function loadSortState() { try { const raw = localStorage.getItem(SORT_STATE_KEY); @@ -716,8 +898,102 @@ }); } - function createCell(content) { + function clearDropMarkers() { + Array.from(headerRow.children).forEach((th) => { + if (th.dataset.columnKey !== draggedColumnKey) { + th.classList.remove('is-dragging'); + } + th.classList.remove('is-drop-before', 'is-drop-after'); + }); + } + + function setupColumnDragAndDrop() { + Array.from(headerCellsByKey.values()).forEach((th) => { + th.addEventListener('dragstart', (event) => { + const key = th.dataset.columnKey; + if (!key) { + event.preventDefault(); + return; + } + + draggedColumnKey = key; + clearDropMarkers(); + th.classList.add('is-dragging'); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + try { + event.dataTransfer.setData('text/plain', key); + } catch (_error) { + // ignore + } + } + }); + + th.addEventListener('dragover', (event) => { + if (!draggedColumnKey || th.dataset.columnKey === draggedColumnKey) { + return; + } + + event.preventDefault(); + clearDropMarkers(); + const rect = th.getBoundingClientRect(); + const insertAfter = event.clientX >= rect.left + (rect.width / 2); + th.classList.toggle('is-drop-before', !insertAfter); + th.classList.toggle('is-drop-after', insertAfter); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + }); + + th.addEventListener('dragleave', () => { + th.classList.remove('is-drop-before', 'is-drop-after'); + }); + + th.addEventListener('drop', (event) => { + event.preventDefault(); + const targetKey = th.dataset.columnKey; + const sourceKey = draggedColumnKey || (event.dataTransfer ? event.dataTransfer.getData('text/plain') : ''); + + if (!sourceKey || !targetKey || sourceKey === targetKey) { + draggedColumnKey = null; + clearDropMarkers(); + return; + } + + const sourceTh = headerCellsByKey.get(sourceKey); + const targetTh = headerCellsByKey.get(targetKey); + if (!sourceTh || !targetTh) { + draggedColumnKey = null; + clearDropMarkers(); + return; + } + + const rect = targetTh.getBoundingClientRect(); + const insertAfter = event.clientX >= rect.left + (rect.width / 2); + if (insertAfter) { + headerRow.insertBefore(sourceTh, targetTh.nextSibling); + } else { + headerRow.insertBefore(sourceTh, targetTh); + } + + draggedColumnKey = null; + clearDropMarkers(); + persistColumnOrder(); + render(); + }); + + th.addEventListener('dragend', () => { + draggedColumnKey = null; + clearDropMarkers(); + }); + }); + } + + function createCell(content, columnKey) { const td = document.createElement('td'); + if (columnKey) { + td.dataset.column = columnKey; + } td.textContent = content; return td; } @@ -757,81 +1033,106 @@ function openTradeFairSearch(row) { const queries = buildTradeFairQueries(row); if (!queries.length) { - return; + return 0; } + let opened = 0; if (typeof window.openBookmarkQueries === 'function') { - const opened = window.openBookmarkQueries(queries); - if (opened > 0) { - return; - } + opened = window.openBookmarkQueries(queries); } - queries.forEach((query) => { - openQueryFallback(query); - }); + if (opened === 0) { + queries.forEach((query) => { + opened += openQueryFallback(query); + }); + } + + if (opened > 0) { + setTradeFairLastOpened(row); + } + return opened; } function createMesseCell(row) { const td = document.createElement('td'); + td.dataset.column = 'messe'; const button = document.createElement('button'); button.type = 'button'; button.className = 'bookmark-subpage__messe-link'; button.textContent = row.messe || 'k.A.'; - button.title = 'Als Bookmark-Suche oeffnen (3 Tabs)'; + button.title = getTradeFairHoverTitle(row); button.addEventListener('click', () => { - openTradeFairSearch(row); + const opened = openTradeFairSearch(row); + if (opened > 0) { + button.title = getTradeFairHoverTitle(row); + render(); + } }); td.appendChild(button); return td; } + function createSourceCell(row) { + const sourceCell = document.createElement('td'); + sourceCell.dataset.column = 'quelle_homepage'; + if (row.quelle_homepage) { + const link = document.createElement('a'); + link.href = row.quelle_homepage; + link.target = '_blank'; + link.rel = 'noopener'; + link.textContent = row.quelle_homepage; + sourceCell.appendChild(link); + } else { + sourceCell.textContent = 'k.A.'; + } + return sourceCell; + } + + function createRowCells(row) { + return { + tage_bis_start: createCell(Number.isFinite(row.tage_bis_start) ? String(row.tage_bis_start) : 'k.A.', 'tage_bis_start'), + rang: createCell(String(row.rang), 'rang'), + messe: createMesseCell(row), + zuletzt_gesucht_am: createCell(formatLastSearched(row.zuletzt_gesucht_am), 'zuletzt_gesucht_am'), + thema: createCell(row.thema, 'thema'), + stadt: createCell(row.stadt, 'stadt'), + bundesland: createCell(row.bundesland, 'bundesland'), + termin_start: createCell(formatDate(row.termin_start), 'termin_start'), + termin_ende: createCell(formatDate(row.termin_ende), 'termin_ende'), + besucher: createCell(formatNumber(row.besucher), 'besucher'), + besucher_jahr: createCell(formatNumber(row.besucher_jahr), 'besucher_jahr'), + besucher_status: createCell(row.besucher_status, 'besucher_status'), + ausstellungsflaeche_m2: createCell(formatNumber(row.ausstellungsflaeche_m2), 'ausstellungsflaeche_m2'), + ticketpreis_we_eur: createCell(formatPrice(row.ticketpreis_we_eur), 'ticketpreis_we_eur'), + ticketpreis_unterderwoche_eur: createCell(formatPrice(row.ticketpreis_unterderwoche_eur), 'ticketpreis_unterderwoche_eur'), + notiz: createCell(row.notiz, 'notiz'), + quelle_homepage: createSourceCell(row) + }; + } + function render() { const normalizedRows = TRADE_FAIRS.map(normalizeRow); const filtered = normalizedRows.filter((row) => matchesSearch(row, searchTerm)); const sorted = sortRows(filtered); + const columnOrder = getColumnOrderFromHeader(); tableBody.innerHTML = ''; if (!sorted.length) { const emptyRow = document.createElement('tr'); const emptyCell = document.createElement('td'); - emptyCell.colSpan = 16; + emptyCell.colSpan = columnOrder.length; emptyCell.textContent = 'Keine Messe zum Suchbegriff gefunden.'; emptyRow.appendChild(emptyCell); tableBody.appendChild(emptyRow); } else { sorted.forEach((row) => { const tr = document.createElement('tr'); - - tr.appendChild(createCell(Number.isFinite(row.tage_bis_start) ? String(row.tage_bis_start) : 'k.A.')); - tr.appendChild(createCell(String(row.rang))); - tr.appendChild(createMesseCell(row)); - tr.appendChild(createCell(row.thema)); - tr.appendChild(createCell(row.stadt)); - tr.appendChild(createCell(row.bundesland)); - tr.appendChild(createCell(formatDate(row.termin_start))); - tr.appendChild(createCell(formatDate(row.termin_ende))); - tr.appendChild(createCell(formatNumber(row.besucher))); - tr.appendChild(createCell(formatNumber(row.besucher_jahr))); - tr.appendChild(createCell(row.besucher_status)); - tr.appendChild(createCell(formatNumber(row.ausstellungsflaeche_m2))); - tr.appendChild(createCell(formatPrice(row.ticketpreis_we_eur))); - tr.appendChild(createCell(formatPrice(row.ticketpreis_unterderwoche_eur))); - tr.appendChild(createCell(row.notiz)); - - const sourceCell = document.createElement('td'); - if (row.quelle_homepage) { - const link = document.createElement('a'); - link.href = row.quelle_homepage; - link.target = '_blank'; - link.rel = 'noopener'; - link.textContent = row.quelle_homepage; - sourceCell.appendChild(link); - } else { - sourceCell.textContent = 'k.A.'; - } - tr.appendChild(sourceCell); + const rowCells = createRowCells(row); + columnOrder.forEach((columnKey) => { + const cell = rowCells[columnKey] || createCell('k.A.', columnKey); + tr.appendChild(cell); + }); tableBody.appendChild(tr); }); @@ -873,6 +1174,9 @@ render(); }); + lastOpenedByTradeFair = loadLastOpenedState(); + loadColumnOrder(); + setupColumnDragAndDrop(); loadSortState(); updateSortButtonState(); render();