/* ============================================= HELLION NEWTAB — boards.js Board & Bookmark Rendering, Modals ============================================= */ let pendingBookmarkBoardId = null; let pendingRenameCallback = null; const SVG_NS = 'http://www.w3.org/2000/svg'; /** * Erzeugt ein SVG-Element mit Attributen und Kinder-Elementen. * @param {string} tag - SVG-Tag (z.B. 'svg', 'circle', 'line') * @param {Object} attrs - Attribute als Key-Value * @param {Array} children - Kind-Elemente * @returns {SVGElement} */ function svgEl(tag, attrs, children) { const el = document.createElementNS(SVG_NS, tag); if (attrs) { for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); } if (children) { for (const child of children) el.appendChild(child); } return el; } /** Erzeugt das 6-Punkt Drag-Handle SVG */ function createDragHandleSvg() { return svgEl('svg', { width: '10', height: '14', viewBox: '0 0 10 14', fill: 'currentColor' }, [ svgEl('circle', { cx: '2', cy: '2', r: '1.5' }), svgEl('circle', { cx: '8', cy: '2', r: '1.5' }), svgEl('circle', { cx: '2', cy: '7', r: '1.5' }), svgEl('circle', { cx: '8', cy: '7', r: '1.5' }), svgEl('circle', { cx: '2', cy: '12', r: '1.5' }), svgEl('circle', { cx: '8', cy: '12', r: '1.5' }), ]); } /** Erzeugt das Plus-Icon SVG */ function createPlusSvg() { return svgEl('svg', { width: '11', height: '11', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [ svgEl('line', { x1: '12', y1: '5', x2: '12', y2: '19' }), svgEl('line', { x1: '5', y1: '12', x2: '19', y2: '12' }), ]); } // ---- RENDER ---- function renderBoards() { const wrapper = document.getElementById('boardsWrapper'); wrapper.replaceChildren(); if (boards.length === 0) { const empty = document.createElement('div'); empty.className = 'empty-state'; const boardStrong = document.createElement('strong'); boardStrong.className = 'accent-text'; boardStrong.textContent = t('boards.add_board'); const importStrong = document.createElement('strong'); importStrong.className = 'accent-text'; importStrong.textContent = t('boards.import'); empty.append( t('boards.empty_state_pre'), boardStrong, t('boards.empty_state_mid'), importStrong, t('boards.empty_state_post') ); wrapper.appendChild(empty); return; } boards.forEach(board => wrapper.appendChild(createBoardEl(board))); initBoardDragDrop(); } function createBoardEl(board) { const div = document.createElement('div'); div.className = 'board' + (board.blurred ? ' blurred' : ''); div.dataset.boardId = board.id; // Header const header = document.createElement('div'); header.className = 'board-header'; const dragHandle = document.createElement('span'); dragHandle.className = 'board-drag-handle'; dragHandle.title = t('boards.drag_title'); dragHandle.appendChild(createDragHandleSvg()); const titleSpanHeader = document.createElement('span'); titleSpanHeader.className = 'board-title'; titleSpanHeader.title = board.title; titleSpanHeader.textContent = board.title; const actions = document.createElement('div'); actions.className = 'board-actions'; const btnBlur = document.createElement('button'); btnBlur.className = 'board-action-btn btn-blur-board'; btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur'); btnBlur.textContent = '\uD83D\uDD12'; const btnRename = document.createElement('button'); btnRename.className = 'board-action-btn btn-rename-board'; btnRename.title = t('boards.rename'); btnRename.textContent = '\u270E'; // Das feste Inbox-Board (Quick-Save-Ziel) darf nicht geloescht werden \u2014 kein Delete-Button. const btnDelete = board.id === 'inbox' ? null : document.createElement('button'); if (btnDelete) { btnDelete.className = 'board-action-btn btn-delete-board'; btnDelete.title = t('boards.delete'); btnDelete.textContent = '\u2715'; } if (btnDelete) { actions.append(btnBlur, btnRename, btnDelete); } else { actions.append(btnBlur, btnRename); } header.append(dragHandle, titleSpanHeader, actions); // Blur-Overlay const blurOverlay = document.createElement('div'); blurOverlay.className = 'board-blur-overlay'; div.appendChild(blurOverlay); btnBlur.addEventListener('click', async e => { e.stopPropagation(); board.blurred = !board.blurred; div.classList.toggle('blurred', board.blurred); btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur'); await saveBoards(); }); blurOverlay.addEventListener('click', async () => { board.blurred = false; div.classList.remove('blurred'); btnBlur.title = t('boards.blur'); await saveBoards(); }); btnRename.addEventListener('click', e => { e.stopPropagation(); openRenameModal(board.title, async newName => { if (!newName.trim()) return; board.title = newName.trim(); await saveBoards(); renderBoards(); }); }); if (btnDelete) btnDelete.addEventListener('click', async e => { e.stopPropagation(); const ok = await HellionDialog.confirm( t('boards.delete_confirm', { title: board.title }), { type: 'danger', title: t('boards.delete_confirm.title'), confirmText: t('boards.delete') } ); if (ok) { // Ganzes board-Objekt (inkl. bookmarks UND blurred-Flag, CR-01) in den Papierkorb. // type:'board', kein originBoardId (Board hat keine Herkunft, Restore legt es direkt in boards[]). // Datensicherheit: ZUERST Trash sichern (saveTrash), DANN Loeschung committen (saveBoards) — // bei Quota-Reject bleibt das Board in boards[], kein Datenverlust. const trashEntry = pushToTrash({ item: board, type: 'board', originBoardId: null }); try { await saveTrash(); boards = boards.filter(b => b.id !== board.id); await saveBoards(); } catch (err) { // Save fehlgeschlagen (z.B. Quota genau zwischen den Writes): auf den Vor-Loesch-Stand // zurueckrollen, damit In-Memory und Storage konsistent bleiben (kein Reload-Duplikat). trash = trash.filter(t => t !== trashEntry); if (!boards.some(b => b.id === board.id)) boards.push(board); console.error('Board-Loeschen fehlgeschlagen, zurueckgerollt:', err && err.message); } renderBoards(); } }); // Bookmark List const list = document.createElement('ul'); list.className = 'board-list'; list.dataset.boardId = board.id; const visibleCount = settings.hideExtra ? settings.visibleCount : board.bookmarks.length; const visible = board.bookmarks.slice(0, visibleCount); const hidden = board.bookmarks.slice(visibleCount); visible.forEach(bm => list.appendChild(createBmEl(bm))); div.appendChild(header); div.appendChild(list); // Event Delegation für Bookmark-Klicks und -Löschungen bindBoardListEvents(list, board); // Show More if (hidden.length > 0) { let expanded = false; let hiddenEls = []; const showMoreBtn = document.createElement('button'); showMoreBtn.className = 'show-more-btn'; showMoreBtn.textContent = t('boards.show_more', { count: hidden.length }); showMoreBtn.addEventListener('click', () => { if (!expanded) { hidden.forEach(bm => { const el = createBmEl(bm); hiddenEls.push(el); list.appendChild(el); }); showMoreBtn.textContent = t('boards.show_less'); expanded = true; } else { hiddenEls.forEach(el => el.remove()); hiddenEls = []; showMoreBtn.textContent = t('boards.show_more', { count: hidden.length }); expanded = false; } }); div.appendChild(showMoreBtn); } // Add Bookmark const addBtn = document.createElement('button'); addBtn.className = 'add-bm-btn'; addBtn.appendChild(createPlusSvg()); addBtn.append(t('boards.add_link')); addBtn.addEventListener('click', () => openAddBookmarkModal(board.id)); div.appendChild(addBtn); initBookmarkDragDrop(list, board); return div; } function createBmEl(bm) { const li = document.createElement('li'); li.className = 'bm-item'; li.dataset.bmId = bm.id; li.dataset.bmUrl = bm.url; li.draggable = true; li.setAttribute('role', 'link'); li.setAttribute('tabindex', '0'); li.setAttribute('aria-label', bm.title); const favicon = document.createElement('div'); favicon.className = 'bm-favicon-local'; favicon.textContent = bm.title.charAt(0).toUpperCase(); const hue = (bm.title.charCodeAt(0) * 137) % 360; favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`; const textDiv = document.createElement('div'); textDiv.className = 'bm-text'; const titleSpan = document.createElement('span'); titleSpan.className = 'bm-title'; titleSpan.title = bm.title; titleSpan.textContent = bm.title; const descSpan = document.createElement('span'); descSpan.className = 'bm-desc'; descSpan.textContent = bm.desc || ''; textDiv.appendChild(titleSpan); textDiv.appendChild(descSpan); const deleteBtn = document.createElement('button'); deleteBtn.className = 'bm-delete'; deleteBtn.title = t('boards.remove_bookmark'); deleteBtn.textContent = '✕'; li.appendChild(favicon); li.appendChild(textDiv); li.appendChild(deleteBtn); return li; } // Event Delegation: Ein Listener pro Board-Liste statt pro Bookmark function bindBoardListEvents(list, board) { list.addEventListener('click', async e => { const bmItem = e.target.closest('.bm-item'); if (!bmItem) return; // Delete-Button geklickt: kein Confirm (wie bisher), aber nicht mehr hart loeschen — // das Bookmark wandert in den Papierkorb (30 Tage, TRASH-01). Erst per find() greifen, // dann mit Herkunft (originBoardId), type und Zeitstempel ins trash[] pushen. if (e.target.closest('.bm-delete')) { e.stopPropagation(); const bmId = bmItem.dataset.bmId; const removed = board.bookmarks.find(b => b.id === bmId); if (removed) { // Datensicherheit: ZUERST den Trash-Klon persistieren, DANN die Loeschung committen. // Falls saveTrash() (Quota) rejectet, ist das Original noch in boards[] -> kein Verlust. const trashEntry = pushToTrash({ item: removed, type: 'bookmark', originBoardId: board.id }); try { await saveTrash(); board.bookmarks = board.bookmarks.filter(b => b.id !== bmId); await saveBoards(); } catch (err) { // Save fehlgeschlagen: auf den Vor-Loesch-Stand zurueckrollen (kein Reload-Duplikat). trash = trash.filter(t => t !== trashEntry); if (!board.bookmarks.some(b => b.id === bmId)) board.bookmarks.push(removed); console.error('Bookmark-Loeschen fehlgeschlagen, zurueckgerollt:', err && err.message); } } renderBoards(); return; } // Bookmark-Link geklickt const url = bmItem.dataset.bmUrl; if (url) { window.open(url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer'); } }); // Tastatur: Enter oeffnet den Bookmark wie ein Klick. role="link" erwartet // nur Enter (Space ist Button-Semantik). Der Delete-Button bleibt ein echter //