feat(layout): Board-Position per Lock-Button fixieren

Neuer Pin-Button (custom SVG, kein Emoji) im Board-Header sperrt die Position eines
Boards. Bei gesperrtem Board (.board.locked):
- der Drag-Handle wird per CSS ausgeblendet (Flos Wunsch: Handle weg statt nur inaktiv),
- ein zweiter Guard in drag.js onDown verweigert zusaetzlich jeden Drag.
Schuetzt vor versehentlichem Verschieben (ergaenzt den 3px-Bewegungs-Schwellwert). locked
wird wie blurred persistiert, im Export/Import durchgereicht und mit ins Trash-Board geklont.
i18n DE/EN ergaenzt.
This commit is contained in:
2026-06-14 20:18:00 +02:00
parent 520a062049
commit d041c66dfb
7 changed files with 40 additions and 4 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ All notable changes per version. Format based on [Keep a Changelog](https://keep
- **Command Palette (Ctrl+K)** — Overlay that live-filters bookmarks (title and URL) and board names from the keyboard. Arrow-key navigation, Enter opens the match, Escape closes. Read-only navigation, separate from the web search bar. Combobox/listbox ARIA pattern with focus trap and focus return. New DE/EN i18n strings. - **Command Palette (Ctrl+K)** — Overlay that live-filters bookmarks (title and URL) and board names from the keyboard. Arrow-key navigation, Enter opens the match, Escape closes. Read-only navigation, separate from the web search bar. Combobox/listbox ARIA pattern with focus trap and focus return. New DE/EN i18n strings.
- **Trash** — Deleted bookmarks and boards move to a 30-day trash instead of vanishing. Restore or permanently remove them from a new Settings section; entries older than 30 days are cleaned up automatically. Stored under its own storage key with a hard size cap so it cannot exhaust the storage quota. - **Trash** — Deleted bookmarks and boards move to a 30-day trash instead of vanishing. Restore or permanently remove them from a new Settings section; entries older than 30 days are cleaned up automatically. Stored under its own storage key with a hard size cap so it cannot exhaust the storage quota.
- **Quick Save** — A global keyboard shortcut (default Alt+Shift+S, configurable in the browser shortcut settings) saves the current tab into a fixed Inbox board from any page. Backed by a background worker (service worker on Chrome/Opera, event page on Firefox) that appends to a dedicated pending queue, which the dashboard drains into the Inbox — separate write domains, so a save can never clobber the boards. A badge confirms the save, and open dashboard tabs sync the new bookmark live via a storage-change listener. - **Quick Save** — A global keyboard shortcut (default Alt+Shift+S, configurable in the browser shortcut settings) saves the current tab into a fixed Inbox board from any page. Backed by a background worker (service worker on Chrome/Opera, event page on Firefox) that appends to a dedicated pending queue, which the dashboard drains into the Inbox — separate write domains, so a save can never clobber the boards. A badge confirms the save, and open dashboard tabs sync the new bookmark live via a storage-change listener.
- **Free layout (bonus)** — Boards can be dragged to free positions via a drag handle, persisted per board. Positions are clamped back into view when the window shrinks, and the layout falls back to a stacked column on small screens. - **Free layout (bonus)** — Boards can be dragged to free positions via a drag handle, persisted per board. Positions are clamped back into view when the window shrinks, and the layout falls back to a stacked column on small screens. Each board can be pinned with a lock button: a locked board cannot be moved (its drag handle is hidden), preventing accidental repositioning. A drag only counts past a small movement threshold, so a mere click on the handle never shifts a board.
### Changed ### Changed
- The bookmark- and board-delete paths no longer remove entries immediately; deletions now route through the trash. - The bookmark- and board-delete paths no longer remove entries immediately; deletions now route through the trash.
+4
View File
@@ -576,6 +576,10 @@ html, body {
} }
.board-drag-handle:hover { color: var(--accent); } .board-drag-handle:hover { color: var(--accent); }
.board-drag-handle:active { cursor: grabbing; } .board-drag-handle:active { cursor: grabbing; }
/* Gesperrtes Board (Position fixiert): Drag-Handle ausblenden (kein Verschieben mehr),
Lock-Button aktiv einfaerben. Hoehere Spezifitaet als .board-drag-handle -> display:none gewinnt. */
.board.locked .board-drag-handle { display: none; }
.board.locked .btn-lock-board { color: var(--accent); }
.board-title { .board-title {
font-family: var(--font-display); font-family: var(--font-display);
+25 -3
View File
@@ -46,6 +46,13 @@ function createPlusSvg() {
]); ]);
} }
/** Erzeugt das Pin-/Reisszwecke-Icon SVG (Position fixieren) — bewusst KEIN Emoji (custom SVG). */
function createPinSvg() {
return svgEl('svg', { width: '11', height: '12', viewBox: '0 0 24 24', fill: 'currentColor' }, [
svgEl('path', { d: 'M16 9V4l1 0c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1l1 0v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z' }),
]);
}
// ---- POS-MIGRATION ---- // ---- POS-MIGRATION ----
// Boards ohne pos (Altbestand vor v2.3) aus einem Auto-Raster befuellen, // Boards ohne pos (Altbestand vor v2.3) aus einem Auto-Raster befuellen,
// damit sie sich nicht alle auf (0,0) stapeln. Raster orientiert sich am // damit sie sich nicht alle auf (0,0) stapeln. Raster orientiert sich am
@@ -113,7 +120,7 @@ function renderBoards() {
function createBoardEl(board) { function createBoardEl(board) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'board' + (board.blurred ? ' blurred' : ''); div.className = 'board' + (board.blurred ? ' blurred' : '') + (board.locked ? ' locked' : '');
div.dataset.boardId = board.id; div.dataset.boardId = board.id;
// Header // Header
@@ -133,6 +140,11 @@ function createBoardEl(board) {
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'board-actions'; actions.className = 'board-actions';
const btnLock = document.createElement('button');
btnLock.className = 'board-action-btn btn-lock-board';
btnLock.title = board.locked ? t('boards.unlock') : t('boards.lock');
btnLock.appendChild(createPinSvg());
const btnBlur = document.createElement('button'); const btnBlur = document.createElement('button');
btnBlur.className = 'board-action-btn btn-blur-board'; btnBlur.className = 'board-action-btn btn-blur-board';
btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur'); btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur');
@@ -152,9 +164,9 @@ function createBoardEl(board) {
} }
if (btnDelete) { if (btnDelete) {
actions.append(btnBlur, btnRename, btnDelete); actions.append(btnLock, btnBlur, btnRename, btnDelete);
} else { } else {
actions.append(btnBlur, btnRename); actions.append(btnLock, btnBlur, btnRename);
} }
header.append(dragHandle, titleSpanHeader, actions); header.append(dragHandle, titleSpanHeader, actions);
@@ -163,6 +175,16 @@ function createBoardEl(board) {
blurOverlay.className = 'board-blur-overlay'; blurOverlay.className = 'board-blur-overlay';
div.appendChild(blurOverlay); div.appendChild(blurOverlay);
btnLock.addEventListener('click', async e => {
e.stopPropagation();
// Position fixieren: blendet via .board.locked den Drag-Handle aus (CSS) und der onDown-Guard
// in drag.js verweigert zusaetzlich den Drag. Reiner Klassen-Toggle, kein Re-Render noetig.
board.locked = !board.locked;
div.classList.toggle('locked', board.locked);
btnLock.title = board.locked ? t('boards.unlock') : t('boards.lock');
await saveBoards();
});
btnBlur.addEventListener('click', async e => { btnBlur.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation();
board.blurred = !board.blurred; board.blurred = !board.blurred;
+2
View File
@@ -72,6 +72,7 @@ function initDataButtons() {
id: b.id || uid(), id: b.id || uid(),
title: String(b.title).slice(0, 100), title: String(b.title).slice(0, 100),
blurred: !!b.blurred, blurred: !!b.blurred,
locked: !!b.locked,
bookmarks: b.bookmarks bookmarks: b.bookmarks
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url)) .filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
.map(bm => ({ .map(bm => ({
@@ -109,6 +110,7 @@ function initDataButtons() {
id: e.item.id || uid(), id: e.item.id || uid(),
title: String(e.item.title || '').slice(0, 100), title: String(e.item.title || '').slice(0, 100),
blurred: !!e.item.blurred, blurred: !!e.item.blurred,
locked: !!e.item.locked,
// Position erhalten, damit ein wiederhergestelltes Board an seinem Platz landet. // Position erhalten, damit ein wiederhergestelltes Board an seinem Platz landet.
...(safePos(e.item.pos) ? { pos: safePos(e.item.pos) } : {}), ...(safePos(e.item.pos) ? { pos: safePos(e.item.pos) } : {}),
bookmarks: Array.isArray(e.item.bookmarks) bookmarks: Array.isArray(e.item.bookmarks)
+3
View File
@@ -21,6 +21,9 @@ function initBoardDragDrop() {
handle.addEventListener('pointerdown', function onDown(e) { handle.addEventListener('pointerdown', function onDown(e) {
// Auf Mobil ist .board position:static (Stapel) -> kein Free-Move. // Auf Mobil ist .board position:static (Stapel) -> kein Free-Move.
if (getComputedStyle(boardEl).position !== 'absolute') return; if (getComputedStyle(boardEl).position !== 'absolute') return;
// Gesperrtes Board (Position fixiert, LAYOUT-LOCK) nicht verschieben. Der Drag-Handle ist
// bei .locked schon per CSS ausgeblendet; dieser Guard ist die zweite Sicherung.
if (boardEl.classList.contains('locked')) return;
e.preventDefault(); e.preventDefault();
handle.setPointerCapture(e.pointerId); handle.setPointerCapture(e.pointerId);
// .board.dragging hebt das Board per CSS nach vorne (z-index) UND ist das Signal fuer den // .board.dragging hebt das Board per CSS nach vorne (z-index) UND ist das Signal fuer den
+4
View File
@@ -21,6 +21,8 @@ const STRINGS = {
'boards.drag_title': 'Board verschieben', 'boards.drag_title': 'Board verschieben',
'boards.blur': 'Blur (privat)', 'boards.blur': 'Blur (privat)',
'boards.unblur': 'Unblur', 'boards.unblur': 'Unblur',
'boards.lock': 'Position sperren',
'boards.unlock': 'Position entsperren',
'boards.rename': 'Umbenennen', 'boards.rename': 'Umbenennen',
'boards.delete': 'Löschen', 'boards.delete': 'Löschen',
'boards.delete_confirm': 'Board „{title}" wirklich löschen?', 'boards.delete_confirm': 'Board „{title}" wirklich löschen?',
@@ -480,6 +482,8 @@ const STRINGS = {
'boards.drag_title': 'Move board', 'boards.drag_title': 'Move board',
'boards.blur': 'Blur (private)', 'boards.blur': 'Blur (private)',
'boards.unblur': 'Unblur', 'boards.unblur': 'Unblur',
'boards.lock': 'Lock position',
'boards.unlock': 'Unlock position',
'boards.rename': 'Rename', 'boards.rename': 'Rename',
'boards.delete': 'Delete', 'boards.delete': 'Delete',
'boards.delete_confirm': 'Really delete board "{title}"?', 'boards.delete_confirm': 'Really delete board "{title}"?',
+1
View File
@@ -54,6 +54,7 @@ function getDefaultBoards() {
{ id: uid(), title: 'Next.js Docs', url: 'https://nextjs.org/docs', desc: '' }, { id: uid(), title: 'Next.js Docs', url: 'https://nextjs.org/docs', desc: '' },
], ],
blurred: false, blurred: false,
locked: false,
pos: { x: 40, y: 110 } pos: { x: 40, y: 110 }
} }
]; ];