Compare commits

...

65 Commits

Author SHA1 Message Date
JonKazama-Hellion 083e78e693 merge: v2.4.0 theme builder into development 2026-06-15 08:11:12 +02:00
JonKazama-Hellion 0001de7dd7 chore(release): Version-Bump 2.4.0 (6 Stellen) + CHANGELOG 2026-06-15 08:10:43 +02:00
JonKazama-Hellion c985a531ef fix(theme): bgLayer beim Custom-Wechsel ohne eigenes Bild leeren
Preset->Custom liess das alte Preset-Hintergrundbild im bgLayer haengen,
weil applyCustomTheme den bgLayer nie anfasste. Jetzt wird er geleert,
wenn keine gueltige bgUrl gesetzt ist, sodass --bg-primary (Solid) durchscheint.
2026-06-15 04:30:51 +02:00
JonKazama-Hellion 2af52fc46d feat(theme): Verdrahtung Custom-Theme (applySettings, selectThemeCard, Picker, Reset, Modal-Sync) 2026-06-15 04:00:59 +02:00
JonKazama-Hellion 1bd2cbb9ad feat(theme): applyCustomTheme/clearCustomTheme/syncCustomPickers + Hex-Validierung + WCAG-Kontrast 2026-06-15 03:31:25 +02:00
JonKazama-Hellion d305d37da5 feat(theme): Eigenes-Kachel + 6-Picker-Panel im Theme-Modal 2026-06-15 03:03:29 +02:00
JonKazama-Hellion 96d4eaa8a1 feat(theme): [data-theme=custom]-Block + Theme-Builder-Panel-Styling 2026-06-15 02:34:42 +02:00
JonKazama-Hellion 22e74d41bc feat(theme): i18n-Keys für Theme-Builder + Onboarding-Wortlaut entschaerft 2026-06-15 02:06:38 +02:00
JonKazama-Hellion d0feddbda0 feat(theme): customTheme-Default im State + Reset-Literal 2026-06-15 01:38:55 +02:00
JonKazama-Hellion 9beeec3182 feat(theme): eigenes Hintergrundbild um https-URLs und Quota-Schutz erweitern
- isValidBgUrl akzeptiert jetzt https:// zusätzlich zu data:/blob: (http bleibt
  ausgeschlossen wegen Mixed-Content)
- CSP img-src 'self' https: data: blob: in allen 3 Manifesten, damit remote
  Hintergründe deterministisch laden statt still am CSP-Default zu haengen
- Upload-Bilder werden vor dem Speichern per Canvas auf die Bildschirmkante
  (max 2560px) verkleinert und als WebP re-kodiert -> schont chrome.storage.local
- URL-Feld: Platzhalter lokalisierbar (data-i18n-placeholder) + Tracking-Hinweis,
  dass ein per URL geladenes Bild bei jedem Oeffnen vom fremden Server kommt
- i18n DE/EN: bg_url.desc + bg_invalid_url an https angepasst, 2 neue Keys
2026-06-14 21:34:34 +02:00
JonKazama-Hellion 42e3cf0dec ci(release): Release via Gitea-API (curl) statt go-basierter release-action
Code Quality / Validate Extension (push) Successful in 5s
Release / Build & Release (push) Successful in 6s
Security / scan (push) Successful in 42s
Die gitea.com/actions/release-action ist 'using: go' und scheitert auf dem Forge-Runner
mit exit 127 — act_runner v0.6.1 bekommt die go-Action weder im Job-Image noch im Runner
kompiliert ('go: executable file not found'). Der Schritt legt das Release jetzt per curl
gegen die Gitea-API an und laedt die Assets hoch, idempotent (vorhandenes Release/Assets
werden wiederverwendet bzw. ersetzt). Laeuft als normaler run-Step im Job-Image und ist
damit unabhaengig von go-Toolchain, Action-Cache und @main-Drift.
2026-06-14 21:05:36 +02:00
JonKazama-Hellion 8c509647da merge: v2.3.0 bookmark comfort features into development 2026-06-14 20:22:11 +02:00
JonKazama-Hellion 2877edee69 release: v2.3.0
Code Quality / Validate Extension (push) Successful in 5s
Security / scan (push) Successful in 19s
Release / Build & Release (push) Failing after 12s
2026-06-14 20:22:11 +02:00
JonKazama-Hellion d041c66dfb 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.
2026-06-14 20:18:00 +02:00
JonKazama-Hellion 520a062049 fix(quick-save): mid-drag gedrainter Quick-Save wird nach Drag-Ende gerendert
Der Drain laesst renderBoards() aus, solange ein Board- oder Bookmark-Drag laeuft
(replaceChildren wuerde den Drag abreissen) — holte den Render danach aber nie nach,
sodass der gespeicherte Eintrag bis zu einem unabhaengigen Fremd-Render unsichtbar blieb.
Der ausgelassene Render wird jetzt gemerkt (_renderDeferredByDrag) und drag.js ruft am
Ende jedes Drags (onUp/onCancel/Bookmark-dragend) flushQuickSaveRenderIfDeferred nach.
Idempotent: ohne ausstehenden Render kein Extra-Render bei normalen Drags.
2026-06-14 19:55:18 +02:00
JonKazama-Hellion 327bcd3385 fix(quick-save): Drain idempotent (srcId-Dedup) + isSafeUrl-Gate
Zwei Befunde aus der Integrations-Review:
- Race: der Page-Drain und der Worker machen je ein read-modify-write auf
  'quicksave_pending' ohne kontextuebergreifende Atomizitaet. Ein Worker-Append im
  await-Fenster des Drains konnte einen bereits gedrainten Eintrag in der Queue belassen,
  den ein Folge-Drain erneut in die Inbox schrieb (Duplikat). Jede eingespielte Bookmark
  traegt jetzt die Pending-id als srcId; ein erneut auftauchender Eintrag wird uebersprungen
  statt doppelt eingefuegt. boards-Write bleibt vor der Queue-Bereinigung -> kein Verlust.
- Validierung: der Drain hat e.url ohne isSafeUrl gepusht, anders als jeder andere
  Bookmark-Schreibpfad. isSafeUrl (jetzt im DOM-freien quicksave-core, http/https/ftp)
  filtert unsichere/leere Protokolle vor dem Schreiben ins Board.
2026-06-14 19:53:38 +02:00
JonKazama-Hellion 530196ddf7 fix(trash): Import-Cap verdraengt keine lokalen Sole-Copies mehr
Beim Trash-Import sortierte combined.sort+slice(-N) rein nach deletedAt: brachte ein
Backup neuere Eintraege mit, fielen aeltere LOKALE Eintraege aus dem Cap — und die sind
die einzige Kopie der geloeschten Daten (Datenverlust). Jetzt haben lokale Eintraege
Vorrang (alle behalten, sind bereits auf TRASH_MAX_ENTRIES gekappt), Importe fuellen nur
den Rest mit den neuesten auf.
2026-06-14 19:52:16 +02:00
JonKazama-Hellion 17eac64683 fix(layout): geblurrtes Board wieder verschiebbar (Drag-Handle ueber Blur-Overlay)
Das .board-blur-overlay (position:absolute; inset:0; z-index:5) lag im geblurrten
Zustand ueber dem Drag-Handle und schluckte den pointerdown, sodass ein geblurrtes
Board nicht mehr per Handle verschoben werden konnte (stattdessen enthuellte der Klick
es). Der Handle bekommt jetzt position:relative + z-index:6 und liegt damit ueber dem
Overlay; Drag funktioniert, Klick auf den Rest des Boards enthuellt weiterhin.
2026-06-14 19:51:32 +02:00
JonKazama-Hellion 1d17f4d11f fix(layout): Board-Handle-Klick ohne Bewegung ueberschreibt board.pos nicht mehr
Ein reiner Klick/Tap auf den Drag-Handle (ohne echtes Verschieben) hat in onUp den
gegen die Viewport geclampten --board-x/y-Wert zurueckgelesen und als board.pos
persistiert. Bei einem off-screen geclampten Board (nach Fenster-Verkleinerung oder
Import von breiterem Screen) zerstoerte das die wahre Position. Jetzt zaehlt erst eine
Bewegung > 3px als Drag; ohne Bewegung bleibt board.pos unangetastet.
2026-06-14 19:51:15 +02:00
JonKazama-Hellion b3288b47eb docs: add 2.3.0 changelog entry 2026-06-14 15:22:37 +02:00
JonKazama-Hellion 84976f5a10 ci: assert background/action + activeTab/commands in Chrome and Firefox manifests 2026-06-14 15:21:46 +02:00
JonKazama-Hellion 5b18bed9b5 chore: bump version to 2.3.0 across all bump targets 2026-06-14 15:20:07 +02:00
JonKazama-Hellion 70f3f705b4 fix(layout): Phase-5-Review — off-screen-Clamp, Drag-Cleanup, Blur-Position, Import-pos
- Render + neuer debounced Resize-Handler clampen --board-x/y gegen den
  aktuellen Viewport: ein auf breiterem Fenster platziertes Board rendert
  nie mehr off-screen (und damit per Drag unerreichbar). board.pos bleibt
  unveraendert, bei spaeterer Verbreiterung wird die Originalposition erreicht.
- drag.js: cleanup() + pointercancel-Listener. Die Klasse .board.dragging
  klebte bei Touch-Interrupt/Browser-Geste sonst dauerhaft und legte den
  app.js-Sync-Guard (Quick-Save-Render) still.
- main.css: '.board.blurred { position: relative }' entfernt — lag im
  utilities-Layer und schlug das absolute Free-Layout (geblurrtes Board fiel
  aus seiner Position + war nicht mehr drag-bar).
- data.js: board.pos wird beim JSON-Import durchgereicht (safePos-Validierung
  via Number.isFinite), sonst Verlust des frei gesetzten Layouts beim Restore.
2026-06-14 15:16:51 +02:00
JonKazama-Hellion 1d9e9dab81 Freies Layout: Mobil-Reset (<=768/480px) auf gestapeltes Layout, ungeschichtet 2026-06-14 14:59:10 +02:00
JonKazama-Hellion 8401535900 Freies Layout: Board-Drag als Free-Move neu (widgets.js-Vorbild), .board.dragging auf z-index umgewidmet, Reorder-CSS (placeholder/ghost) raus 2026-06-14 14:58:08 +02:00
JonKazama-Hellion 390a9b2f94 Freies Layout: board.pos-Migration aus Auto-Raster, Position als --board-x/y beim Render 2026-06-14 14:55:05 +02:00
JonKazama-Hellion dcc015abd2 Freies Layout: .board absolut via --board-x/--board-y (components-Layer, kein Inline-Style) 2026-06-14 14:53:09 +02:00
JonKazama-Hellion 456be8ba26 Freies Layout: .boards-wrapper auf absolute Kinder vorbereiten (layout-Layer) 2026-06-14 14:52:44 +02:00
JonKazama-Hellion 767c7c80aa fix(quick-save): Drain-Trailing-Re-Run gegen verworfene onChanged waehrend laufendem Drain (Latenz, kein Verlust) 2026-06-14 14:29:47 +02:00
JonKazama-Hellion 43403bc755 fix(quick-save): Pending-Queue-Redesign (Blocker 2b) — Worker schreibt eigenen 'quicksave_pending'-Key statt boards, Seite drained in die Inbox; getrennte Schreib-Domaenen, kein boards-Clobber 2026-06-14 14:27:31 +02:00
JonKazama-Hellion 4897781848 fix(quick-save): Opera-Worker — interne-URL-Filter, kurzer Fehler-Badge, Re-Entry-Schutz gegen Lost-Update 2026-06-14 14:17:46 +02:00
JonKazama-Hellion 5feadcc90c fix(quick-save): Firefox-importScripts-Guard (Event-Page), Sync-Guard auf reale Overlay/Drag-Klassen, Worker-Serialisierung + interne-URL-Filter + kurzer Fehler-Badge 2026-06-14 14:14:31 +02:00
JonKazama-Hellion a37f34eeac fix(manifest): Quick-Save auf Alt+Shift+S (Strg+Shift+S Brave-Konflikt, Strg+Alt verboten); Firefox laedt quicksave-core via scripts-Array 2026-06-14 10:47:17 +02:00
JonKazama-Hellion f473697fb2 fix(backup): Papierkorb (trash) auch im Backup-Reminder-Export, konsistent zum Settings-Export 2026-06-14 10:30:08 +02:00
JonKazama-Hellion 9383726198 feat(quick-save): Live-Sync via chrome.storage.onChanged in app.js (boards neu laden + renderBoards) 2026-06-14 10:28:29 +02:00
JonKazama-Hellion 7d390792ea feat(quick-save): Opera-Worker additiv um onCommand + importScripts ergaenzt, Redirect unberuehrt (CRLF) 2026-06-14 10:26:29 +02:00
JonKazama-Hellion 17506011c1 feat(quick-save): background.js fuer Chrome-Worker + Firefox-Event-Page, read-modify-write in Inbox, Badge-Bestaetigung 2026-06-14 10:24:06 +02:00
JonKazama-Hellion c8ff4dd9d2 manifest(opera): quick-save command additiv, tabs/action/Redirect-Worker unveraendert 2026-06-14 10:22:26 +02:00
JonKazama-Hellion 79459beb98 manifest(firefox): quick-save command, activeTab, Event-Page background.scripts, action-Badge 2026-06-14 10:22:15 +02:00
JonKazama-Hellion 9a682d49a9 manifest(chrome): quick-save command (Strg+Shift+S), activeTab, service_worker, action-Badge 2026-06-14 10:22:00 +02:00
JonKazama-Hellion a9928706ad i18n: Quick-Save command-description + Badge/Confirm-Keys in _locales DE/EN 2026-06-14 10:20:19 +02:00
JonKazama-Hellion 83df926979 fix(trash): Daten-Review-Befunde — Import-Cap nach deletedAt sortiert (Verlust-Schutz), Restore-Doppelklick-Guard, Delete-Rollback bei Save-Fehler, NaN/Null-Haertung 2026-06-14 10:18:10 +02:00
JonKazama-Hellion 9800e6c949 fix(trash): Papierkorb-Eintrag als vertikale Karte, kompaktere Aktions-Buttons (UX im 380px-Panel) 2026-06-14 10:11:43 +02:00
JonKazama-Hellion ba5f5c4978 v2.3 Papierkorb: Export/Import um trash erweitern (defensiv validiert) 2026-06-14 10:02:31 +02:00
JonKazama-Hellion 22203d25a7 v2.3 Papierkorb: renderTrash, Wiederherstellen, endgueltig loeschen, leeren 2026-06-14 09:59:44 +02:00
JonKazama-Hellion da5d8faafa v2.3 Papierkorb: CSS fuer die Papierkorb-Liste (components-Layer) 2026-06-14 09:55:01 +02:00
JonKazama-Hellion 127aba12eb v2.3 Papierkorb: Settings-Section zwischen Daten und Danger Zone 2026-06-14 09:55:01 +02:00
JonKazama-Hellion 4031b429ad v2.3 Papierkorb: i18n-Keys DE und EN 2026-06-14 09:51:57 +02:00
JonKazama-Hellion 62c1ecab8d v2.3 Papierkorb: Inbox-Board ist nicht loeschbar (kein Delete-Button) 2026-06-14 09:48:41 +02:00
JonKazama-Hellion 061c3708bc v2.3 Papierkorb: Board-Loeschen in den Papierkorb umleiten (Confirm bleibt) 2026-06-14 09:48:06 +02:00
JonKazama-Hellion 9abfefc0e0 v2.3 Papierkorb: Bookmark-Loeschen in den Papierkorb umleiten 2026-06-14 09:47:40 +02:00
JonKazama-Hellion 36d917b420 v2.3 Papierkorb: pushToTrash() mit Klon und harter Obergrenze 2026-06-14 09:44:43 +02:00
JonKazama-Hellion fcaea64604 fix(palette): Review-Befunde — Close-Crash-Guard, Self-Block-Race, ARIA-Combobox, URL-Protokoll-Guard 2026-06-14 09:42:00 +02:00
JonKazama-Hellion 6eaa3457d0 v2.3 Papierkorb: Trash-Konstanten und Auto-Cleanup beim Laden 2026-06-14 09:33:47 +02:00
JonKazama-Hellion 091195cdef v2.3: persistenter Header-Trigger fuer die Strg+K-Palette (Entdeckbarkeit, BS-08) 2026-06-14 09:26:22 +02:00
JonKazama-Hellion b5b0ac3471 v2.3: Onboarding-Slide fuer die Strg+K-Palette (Entdeckbarkeit, BS-08) 2026-06-14 09:25:05 +02:00
JonKazama-Hellion 7b16db96b9 style: Command-Palette-Overlay in @layer components 2026-06-14 09:20:51 +02:00
JonKazama-Hellion 3872f4cf12 feat: initPalette() im App-Init nach initSearch() verdrahten 2026-06-14 09:20:04 +02:00
JonKazama-Hellion e7a064783f build: palette.js zwischen search.js und widgets.js laden 2026-06-14 09:19:43 +02:00
JonKazama-Hellion 42860bb95d feat: Command-Palette-Modul (Strg+K, read-only Bookmark-Suche) 2026-06-14 09:15:21 +02:00
JonKazama-Hellion 6a27d9b307 i18n: Keys fuer Command-Palette (DE+EN) 2026-06-14 09:12:49 +02:00
JonKazama-Hellion c96922d1bb v2.3: page-seitiger ensureInboxBoard-Wrapper auf quicksave-core 2026-06-14 09:03:31 +02:00
JonKazama-Hellion 2daccf4ecc v2.3: trash als eigener Store-Key + saveTrash, in init geladen 2026-06-14 08:35:27 +02:00
JonKazama-Hellion ecb44facb5 v2.3: uid-Single-Source in quicksave-core, state.js delegiert 2026-06-14 08:03:18 +02:00
JonKazama-Hellion e1fb580525 v2.3: DOM-freies quicksave-core mit uid/ensureInbox/normalizeBookmark 2026-06-14 07:31:15 +02:00
22 changed files with 1723 additions and 176 deletions
+10
View File
@@ -45,6 +45,11 @@ jobs:
assert m.get('name'), 'Chrome: Name fehlt' assert m.get('name'), 'Chrome: Name fehlt'
assert m.get('version'), 'Chrome: Version fehlt' assert m.get('version'), 'Chrome: Version fehlt'
assert 'storage' in m.get('permissions', []), 'Chrome: Storage Permission fehlt' assert 'storage' in m.get('permissions', []), 'Chrome: Storage Permission fehlt'
assert 'activeTab' in m.get('permissions', []), 'Chrome: activeTab Permission fehlt (Quick Save v2.3)'
assert 'background' in m, 'Chrome: background-Key fehlt (Service Worker v2.3)'
assert 'service_worker' in m.get('background', {}), 'Chrome: background.service_worker fehlt'
assert isinstance(m.get('commands'), dict) and 'quick-save' in m['commands'], 'Chrome: commands.quick-save fehlt (Quick Save v2.3)'
assert 'action' in m, 'Chrome: action-Key fehlt (Badge-Bestätigung v2.3)'
print('manifest.json (V3) OK — Version:', m['version']) print('manifest.json (V3) OK — Version:', m['version'])
with open('manifest.firefox.json') as f: with open('manifest.firefox.json') as f:
@@ -52,6 +57,11 @@ jobs:
assert mf.get('manifest_version') == 3, 'Firefox: Manifest V3 erwartet' assert mf.get('manifest_version') == 3, 'Firefox: Manifest V3 erwartet'
assert mf['version'] == m['version'], 'Firefox: Version stimmt nicht mit Chrome überein!' assert mf['version'] == m['version'], 'Firefox: Version stimmt nicht mit Chrome überein!'
assert 'browser_specific_settings' in mf, 'Firefox: browser_specific_settings fehlt' assert 'browser_specific_settings' in mf, 'Firefox: browser_specific_settings fehlt'
assert 'activeTab' in mf.get('permissions', []), 'Firefox: activeTab Permission fehlt (Quick Save v2.3)'
assert 'background' in mf, 'Firefox: background-Key fehlt (Event-Page v2.3)'
assert 'scripts' in mf.get('background', {}), 'Firefox: background.scripts fehlt (Event-Page, kein service_worker)'
assert isinstance(mf.get('commands'), dict) and 'quick-save' in mf['commands'], 'Firefox: commands.quick-save fehlt (Quick Save v2.3)'
assert 'action' in mf, 'Firefox: action-Key fehlt (Badge-Bestätigung v2.3)'
print('manifest.firefox.json (V3) OK — Version:', mf['version']) print('manifest.firefox.json (V3) OK — Version:', mf['version'])
with open('manifest.opera.json') as f: with open('manifest.opera.json') as f:
+41 -22
View File
@@ -77,28 +77,47 @@ jobs:
sha256sum *.zip > checksums-sha256.txt sha256sum *.zip > checksums-sha256.txt
cat checksums-sha256.txt cat checksums-sha256.txt
# Gitea-native Release-Action. Legt das Release an, falls der Tag noch # Release per Gitea-API (curl), NICHT via gitea.com/actions/release-action: die ist `using: go`
# keins hat, oder aktualisiert das bestehende und haengt die Assets an. # und stirbt auf diesem Runner mit exit 127 ("go not found"), weil act_runner v0.6.1 die go-Action
# Der auto-injizierte GITHUB_TOKEN auf Gitea Actions hat Gitea-API-Scope # weder im Job-Image noch im Runner kompiliert bekommt. curl + python3 sind im Job-Image vorhanden
# und reicht fuer Release-Write. # und laufen als normaler Step -> unabhaengig von go-Toolchain, Action-Cache und @main-Drift.
- name: Attach to Gitea release # GITHUB_API_URL/GITHUB_REPOSITORY/GITHUB_TOKEN injiziert Gitea Actions automatisch.
uses: https://gitea.com/actions/release-action@main - name: Create release & upload assets (Gitea API)
with: env:
files: |- GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip TAG: ${{ steps.version.outputs.tag }}
dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip run: |
dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip set -euo pipefail
dist/checksums-sha256.txt API="${GITHUB_API_URL:-https://gitea.hellion-forge.cloud/api/v1}"
api_key: ${{ secrets.GITHUB_TOKEN }} REPO="${GITHUB_REPOSITORY}"
body: | AUTH="Authorization: token ${GITEA_TOKEN}"
## Hellion NewTab ${{ steps.version.outputs.tag }}
### Installation # Release-Request-JSON (Body inkl. Installationshinweise) als python-Einzeiler bauen
- **Chrome / Edge / Brave / Vivaldi:** `hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip` # (mehrzeilig wuerde den YAML-run-Block brechen: Zeilen auf Spalte 0).
- **Firefox:** `hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip` REQ=$(python3 -c 'import json,os; t=os.environ["TAG"]; body="## Hellion NewTab "+t+"\n\n### Installation\n- **Chrome / Edge / Brave / Vivaldi:** `hellion-newtab-"+t+"-chrome.zip`\n- **Firefox:** `hellion-newtab-"+t+"-firefox.zip`\n- **Opera / Opera GX:** `hellion-newtab-"+t+"-opera.zip`\n\nVollstaendige Installationsanleitung siehe README.\n\n### Checksums\n`checksums-sha256.txt` zum Verifizieren der Dateiintegritaet."; print(json.dumps({"tag_name": t, "name": "Hellion NewTab "+t, "body": body}))')
- **Opera / Opera GX:** `hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip`
Vollstaendige Installationsanleitung siehe README. # Idempotent: existierendes Release zum Tag wiederverwenden, sonst anlegen.
REL_ID=$(curl -sf -H "$AUTH" "$API/repos/$REPO/releases/tags/$TAG" \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true)
if [ -z "$REL_ID" ]; then
REL_ID=$(printf '%s' "$REQ" \
| curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" -d @- "$API/repos/$REPO/releases" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
echo "Release angelegt: $REL_ID"
else
echo "Release existiert bereits, wiederverwenden: $REL_ID"
fi
### Checksums # Vorhandene gleichnamige Assets entfernen (idempotent bei Re-Runs), dann hochladen.
`checksums-sha256.txt` zum Verifizieren der Dateiintegritaet. EXIST=$(curl -sf -H "$AUTH" "$API/repos/$REPO/releases/$REL_ID/assets" 2>/dev/null || echo '[]')
for f in dist/hellion-newtab-$TAG-chrome.zip dist/hellion-newtab-$TAG-firefox.zip dist/hellion-newtab-$TAG-opera.zip dist/checksums-sha256.txt; do
name=$(basename "$f")
aid=$(printf '%s' "$EXIST" | NAME="$name" python3 -c 'import sys,json,os;n=os.environ["NAME"];print(next((a["id"] for a in json.load(sys.stdin) if a.get("name")==n), ""))' 2>/dev/null || true)
if [ -n "$aid" ]; then
echo "ersetze vorhandenes Asset $name (id $aid)"
curl -sf -X DELETE -H "$AUTH" "$API/repos/$REPO/releases/$REL_ID/assets/$aid" >/dev/null || true
fi
echo "Upload $name ..."
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" "$API/repos/$REPO/releases/$REL_ID/assets?name=$name" >/dev/null
done
echo "Release $TAG fertig: alle Assets hochgeladen."
+27
View File
@@ -6,6 +6,33 @@ All notable changes per version. Format based on [Keep a Changelog](https://keep
--- ---
## [2.4.0] — 2026-06-15
### Added
- **Custom theme builder** — A new "Custom" tile in the theme picker opens an inline panel with six colour pickers (accent, background, board surface, and three text levels). Colours apply live to the dashboard; the accent drives the derived glow, border and toggle tints via `color-mix`. A non-blocking WCAG contrast indicator flags hard-to-read text/background combinations without preventing the choice. The custom theme persists across reloads and can be combined with a custom background image. A reset button returns the pickers to neutral defaults. New DE/EN i18n strings; the `<input type="color">` pickers are labelled for accessibility.
- **Custom background via https URL** — The background URL field now accepts `https://` images in addition to local uploads (http stays out to avoid mixed content). A privacy note explains that a URL-loaded image is fetched from the remote server on every new tab.
### Changed
- Uploaded background images are downscaled (to the longest screen edge, capped at 2560px) and re-encoded as WebP before storage, to protect the `chrome.storage.local` quota.
- The extension-page CSP gains `img-src 'self' https: data: blob:` so https and data-URL backgrounds load deterministically instead of relying on the browser default.
- Onboarding slide 3 wording no longer hard-codes a fixed theme count.
---
## [2.3.0] — 2026-06-14
### Added
- **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.
- **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. 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
- The bookmark- and board-delete paths no longer remove entries immediately; deletions now route through the trash.
- Chrome and Firefox manifests gain a background worker, an `action` entry and the `activeTab` / `commands` permissions to support Quick Save. Opera keeps its existing `tabs` permission and redirect worker.
---
## [2.2.0] — 2026-06-13 ## [2.2.0] — 2026-06-13
### Added ### Added
+5 -1
View File
@@ -1,4 +1,8 @@
{ {
"extName": { "message": "Hellion NewTab" }, "extName": { "message": "Hellion NewTab" },
"extDesc": { "message": "Persönliches Bookmark-Dashboard mit Boards, Widgets und 11 Themes. Komplett lokal, keine Cloud, kein Tracking." } "extDesc": { "message": "Persönliches Bookmark-Dashboard mit Boards, Widgets und 11 Themes. Komplett lokal, keine Cloud, kein Tracking." },
"cmdQuickSave": { "message": "Aktuellen Tab in die Inbox speichern" },
"quickSaveBadge": { "message": "OK" },
"quickSaveSaved": { "message": "Gespeichert" },
"quickSaveNoTab": { "message": "Kein Tab" }
} }
+5 -1
View File
@@ -1,4 +1,8 @@
{ {
"extName": { "message": "Hellion NewTab" }, "extName": { "message": "Hellion NewTab" },
"extDesc": { "message": "Personal bookmark dashboard with boards, widgets, and 11 themes. Local-only, no cloud, no tracking." } "extDesc": { "message": "Personal bookmark dashboard with boards, widgets, and 11 themes. Local-only, no cloud, no tracking." },
"cmdQuickSave": { "message": "Save current tab to Inbox" },
"quickSaveBadge": { "message": "OK" },
"quickSaveSaved": { "message": "Saved" },
"quickSaveNoTab": { "message": "No tab" }
} }
+22 -3
View File
@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_extName__", "name": "__MSG_extName__",
"default_locale": "en", "default_locale": "en",
"version": "2.2.0", "version": "2.4.0",
"description": "__MSG_extDesc__", "description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling", "author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de", "homepage_url": "https://hellion-media.de",
@@ -11,9 +11,28 @@
"newtab": "newtab.html" "newtab": "newtab.html"
}, },
"background": {
"scripts": ["src/js/quicksave-core.js", "src/js/background.js"]
},
"action": {
"default_title": "Hellion Dashboard"
},
"commands": {
"quick-save": {
"suggested_key": {
"default": "Alt+Shift+S",
"mac": "Alt+Shift+S"
},
"description": "__MSG_cmdQuickSave__"
}
},
"permissions": [ "permissions": [
"storage", "storage",
"bookmarks" "bookmarks",
"activeTab"
], ],
"browser_specific_settings": { "browser_specific_settings": {
@@ -35,7 +54,7 @@
} }
], ],
"content_security_policy": { "content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'" "extension_pages": "script-src 'self'; object-src 'self'; img-src 'self' https: data: blob:"
}, },
"icons": { "icons": {
"16": "assets/icons/icon16.png", "16": "assets/icons/icon16.png",
+19 -3
View File
@@ -2,16 +2,32 @@
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_extName__", "name": "__MSG_extName__",
"default_locale": "en", "default_locale": "en",
"version": "2.2.0", "version": "2.4.0",
"description": "__MSG_extDesc__", "description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling", "author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de", "homepage_url": "https://hellion-media.de",
"chrome_url_overrides": { "chrome_url_overrides": {
"newtab": "newtab.html" "newtab": "newtab.html"
}, },
"background": {
"service_worker": "src/js/background.js"
},
"action": {
"default_title": "Hellion Dashboard"
},
"commands": {
"quick-save": {
"suggested_key": {
"default": "Alt+Shift+S",
"mac": "Alt+Shift+S"
},
"description": "__MSG_cmdQuickSave__"
}
},
"permissions": [ "permissions": [
"storage", "storage",
"bookmarks" "bookmarks",
"activeTab"
], ],
"web_accessible_resources": [ "web_accessible_resources": [
{ {
@@ -20,7 +36,7 @@
} }
], ],
"content_security_policy": { "content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'" "extension_pages": "script-src 'self'; object-src 'self'; img-src 'self' https: data: blob:"
}, },
"icons": { "icons": {
"16": "assets/icons/icon16.png", "16": "assets/icons/icon16.png",
+12 -2
View File
@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_extName__", "name": "__MSG_extName__",
"default_locale": "en", "default_locale": "en",
"version": "2.2.0", "version": "2.4.0",
"description": "__MSG_extDesc__", "description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling", "author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de", "homepage_url": "https://hellion-media.de",
@@ -40,8 +40,18 @@
"default_title": "Hellion Dashboard" "default_title": "Hellion Dashboard"
}, },
"commands": {
"quick-save": {
"suggested_key": {
"default": "Alt+Shift+S",
"mac": "Alt+Shift+S"
},
"description": "__MSG_cmdQuickSave__"
}
},
"content_security_policy": { "content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'" "extension_pages": "script-src 'self'; object-src 'self'; img-src 'self' https: data: blob:"
}, },
"icons": { "icons": {
"16": "assets/icons/icon16.png", "16": "assets/icons/icon16.png",
+40 -2
View File
@@ -39,6 +39,10 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 100 20 4 4 0 01-1-7.9 1 1 0 011-.1h1a2 2 0 002-2V7a5 5 0 00-3-4.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="6" r="1.5"/><circle cx="17" cy="10" r="1.5"/><circle cx="9" cy="17" r="1.5"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 100 20 4 4 0 01-1-7.9 1 1 0 011-.1h1a2 2 0 002-2V7a5 5 0 00-3-4.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="6" r="1.5"/><circle cx="17" cy="10" r="1.5"/><circle cx="9" cy="17" r="1.5"/></svg>
<span data-i18n="header.theme">Darstellung</span> <span data-i18n="header.theme">Darstellung</span>
</button> </button>
<button class="btn-icon" id="btnPalette" title="Lesezeichen durchsuchen (Strg+K)" data-i18n-title="palette.discover">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span data-i18n="palette.discover_label">Suchen</span>
</button>
<button class="btn-icon" id="btnSettings" title="Einstellungen" data-i18n-title="header.settings_title"> <button class="btn-icon" id="btnSettings" title="Einstellungen" data-i18n-title="header.settings_title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span data-i18n="header.settings">Settings</span> <span data-i18n="header.settings">Settings</span>
@@ -200,6 +204,20 @@
</div> </div>
</section> </section>
<!-- PAPIERKORB / TRASH -->
<section class="settings-section" data-section="trash">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
<span data-i18n="trash.section">PAPIERKORB</span>
</button>
<div class="section-content">
<div class="trash-list" id="trashList"></div>
<div class="setting-row trash-actions" id="trashActionsRow">
<button class="btn-danger" id="btnEmptyTrash" data-i18n="trash.empty_btn">Papierkorb leeren</button>
</div>
</div>
</section>
<!-- DANGER ZONE --> <!-- DANGER ZONE -->
<section class="settings-section" data-section="danger"> <section class="settings-section" data-section="danger">
<button class="settings-section-title danger" type="button"> <button class="settings-section-title danger" type="button">
@@ -223,7 +241,7 @@
<div class="panel-footer"> <div class="panel-footer">
<div class="about-block"> <div class="about-block">
<div class="about-logo" data-i18n="about.title">⬡ HELLION NEWTAB</div> <div class="about-logo" data-i18n="about.title">⬡ HELLION NEWTAB</div>
<div class="about-version">Version 2.2.0 · by Hellion Online Media</div> <div class="about-version">Version 2.4.0 · by Hellion Online Media</div>
<div class="about-links"> <div class="about-links">
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link"> <a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
@@ -352,6 +370,23 @@
<span class="theme-card-label">Stealth</span> <span class="theme-card-label">Stealth</span>
<span class="theme-card-check"></span> <span class="theme-card-check"></span>
</div> </div>
<div class="theme-card theme-card-custom" data-value="custom" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.custom">
<span class="theme-card-custom-swatch"></span>
<span class="theme-card-label" data-i18n="theme.builder.title">Eigenes</span>
<span class="theme-card-check"></span>
</div>
</div>
<div class="theme-builder-panel hidden" id="themeBuilderPanel">
<div class="tb-grid">
<div class="tb-picker"><input type="color" id="tbAccent" value="#6c8cff"><label for="tbAccent" data-i18n="theme.builder.accent">Akzent</label></div>
<div class="tb-picker"><input type="color" id="tbBg" value="#0b0d12"><label for="tbBg" data-i18n="theme.builder.bg">Hintergrund</label></div>
<div class="tb-picker"><input type="color" id="tbBoard" value="#141821"><label for="tbBoard" data-i18n="theme.builder.board">Board-Fläche</label></div>
<div class="tb-picker"><input type="color" id="tbText" value="#e6e8ef"><label for="tbText" data-i18n="theme.builder.text">Text primär</label></div>
<div class="tb-picker"><input type="color" id="tbTextSec" value="#9aa3b8"><label for="tbTextSec" data-i18n="theme.builder.text_secondary">Text sekundär</label></div>
<div class="tb-picker"><input type="color" id="tbTextMuted" value="#5b6478"><label for="tbTextMuted" data-i18n="theme.builder.text_muted">Text gedämpft</label></div>
</div>
<div class="tb-contrast good" id="tbContrast"><span class="tb-dot"></span><span id="tbContrastText" data-i18n="theme.builder.contrast_good">Gut lesbar</span></div>
<div class="tb-foot"><button class="tb-reset" id="tbReset" data-i18n="theme.builder.reset">Zurücksetzen</button></div>
</div> </div>
<div class="theme-modal-section"> <div class="theme-modal-section">
<h3 class="settings-section-title" data-i18n="settings.section.bg">HINTERGRUND</h3> <h3 class="settings-section-title" data-i18n="settings.section.bg">HINTERGRUND</h3>
@@ -363,9 +398,10 @@
<button class="btn-small" id="btnChangeBg" data-i18n="settings.bg_change">Ändern</button> <button class="btn-small" id="btnChangeBg" data-i18n="settings.bg_change">Ändern</button>
</div> </div>
<div class="setting-row hidden" id="bgInputRow"> <div class="setting-row hidden" id="bgInputRow">
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... oder leer für Standard" /> <input type="text" class="text-input full-width" id="bgUrlInput" data-i18n-placeholder="settings.bg_url.placeholder" placeholder="https:// oder leer für Standard" />
<button class="btn-small" id="btnApplyBg" data-i18n="settings.bg_apply">Übernehmen</button> <button class="btn-small" id="btnApplyBg" data-i18n="settings.bg_apply">Übernehmen</button>
</div> </div>
<p class="setting-desc bg-url-hint hidden" id="bgUrlHint" data-i18n="settings.bg_url.privacy_hint">Hinweis: Ein per URL eingebundenes Bild wird bei jedem Öffnen vom fremden Server geladen.</p>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<span class="setting-label" data-i18n="settings.bg_upload">Datei hochladen</span> <span class="setting-label" data-i18n="settings.bg_upload">Datei hochladen</span>
@@ -492,6 +528,7 @@
<!-- Storage muss zuerst --> <!-- Storage muss zuerst -->
<script src="src/js/storage.js"></script> <script src="src/js/storage.js"></script>
<!-- State & Hilfsfunktionen --> <!-- State & Hilfsfunktionen -->
<script src="src/js/quicksave-core.js"></script>
<script src="src/js/state.js"></script> <script src="src/js/state.js"></script>
<!-- i18n (nach state.js, vor allen Modulen die t() nutzen könnten) --> <!-- i18n (nach state.js, vor allen Modulen die t() nutzen könnten) -->
<script src="src/js/i18n.js"></script> <script src="src/js/i18n.js"></script>
@@ -504,6 +541,7 @@
<script src="src/js/boards.js"></script> <script src="src/js/boards.js"></script>
<script src="src/js/settings.js"></script> <script src="src/js/settings.js"></script>
<script src="src/js/search.js"></script> <script src="src/js/search.js"></script>
<script src="src/js/palette.js"></script>
<script src="src/js/widgets.js"></script> <script src="src/js/widgets.js"></script>
<script src="src/js/notes.js"></script> <script src="src/js/notes.js"></script>
<script src="src/js/calculator.js"></script> <script src="src/js/calculator.js"></script>
+204 -19
View File
@@ -349,6 +349,30 @@
--toggle-on-bg: color-mix(in srgb, var(--accent) var(--toggle-on-bg-pct), transparent); --toggle-on-bg: color-mix(in srgb, var(--accent) var(--toggle-on-bg-pct), transparent);
--bg-solid-fallback: #0d0f12; --bg-solid-fallback: #0d0f12;
} }
/* ============================================
THEME: CUSTOM (User-Theme-Builder, neutrale Defaults)
Inline-Vars aus applyCustomTheme() ueberschreiben die 6 Picker-Werte.
============================================ */
[data-theme="custom"] {
--accent: #6c8cff;
--accent-glow-pct: 5%;
--board-hover-border-pct: 20%;
--logo-shadow-pct: 38%;
--toggle-on-bg-pct: 20%;
--bg-primary: #0b0d12;
--bg-board: rgba(20, 24, 33, 0.55);
--border: rgba(255, 255, 255, 0.06);
--text-primary: #e6e8ef;
--text-secondary: #9aa3b8;
--text-muted: #5b6478;
--font-display: 'Rajdhani', sans-serif;
--font-body: 'Inter', sans-serif;
--overlay-bg: radial-gradient(circle at center, color-mix(in srgb, var(--bg-primary) 35%, transparent) 0%, color-mix(in srgb, var(--bg-primary) 88%, transparent) 100%);
--header-bg: color-mix(in srgb, var(--bg-primary) 92%, transparent);
--toggle-on-bg: color-mix(in srgb, var(--accent) var(--toggle-on-bg-pct), transparent);
--bg-solid-fallback: var(--bg-primary);
}
} }
/* ============================================ /* ============================================
@@ -413,6 +437,8 @@
[data-theme="hellion-stealth"] .board-title { text-transform: uppercase; font-size: 0.85rem; letter-spacing: 2px; } [data-theme="hellion-stealth"] .board-title { text-transform: uppercase; font-size: 0.85rem; letter-spacing: 2px; }
[data-theme="hellion-stealth"] .board { border-color: rgba(94, 194, 255, 0.15); backdrop-filter: blur(10px); } [data-theme="hellion-stealth"] .board { border-color: rgba(94, 194, 255, 0.15); backdrop-filter: blur(10px); }
[data-theme="hellion-stealth"] .bm-item:hover { background: rgba(94, 194, 255, 0.10); border-left: 2px solid var(--accent); } [data-theme="hellion-stealth"] .bm-item:hover { background: rgba(94, 194, 255, 0.10); border-left: 2px solid var(--accent); }
[data-theme="custom"] .board { border-color: color-mix(in srgb, var(--accent) 15%, transparent); }
[data-theme="custom"] .bm-item:hover { background: color-mix(in srgb, var(--accent) 7%, transparent); }
} }
/* ============================================ /* ============================================
@@ -513,17 +539,29 @@ html, body {
/* BOARDS */ /* BOARDS */
.boards-wrapper { .boards-wrapper {
position: relative; z-index: 10; position: relative; z-index: 10;
/* Free-Layout: Boards liegen absolut (siehe @layer components .board).
Der Flex-Modus bleibt als Fallback fuer den Mobil-Reset erhalten
(dort wird .board wieder position:static, dann greift flex-wrap). */
display: flex; flex-wrap: wrap; display: flex; flex-wrap: wrap;
align-content: flex-start; align-content: flex-start;
justify-content: center; justify-content: flex-start;
gap: 14px; gap: 14px;
padding: 110px 40px 40px; padding: 110px 40px 40px;
/* Absolute Kinder beanspruchen keinen Platz -> Wrapper braucht eine
explizite Mindesthoehe, sonst kollabiert er auf die Padding-Hoehe. */
min-height: 100vh; min-height: 100vh;
} }
} }
@layer components { @layer components {
.board { .board {
/* Freies Layout: Position kommt aus --board-x/--board-y (von drag.js gesetzt),
NICHT inline, damit der ungeschichtete Mobil-@media-Block sie ueberschreiben
kann (Inline-Style wuerde gegen das Stylesheet gewinnen). Default 0/0 fuer
den Fall, dass die Migration noch nicht gelaufen ist. */
position: absolute;
left: var(--board-x, 0px);
top: var(--board-y, 0px);
width: var(--board-width); width: var(--board-width);
background: var(--bg-board); background: var(--bg-board);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -556,9 +594,18 @@ html, body {
flex-shrink: 0; flex-shrink: 0;
transition: color 0.15s; transition: color 0.15s;
touch-action: none; touch-action: none;
/* Ueber dem Blur-Overlay (.board-blur-overlay, z-index:5) liegen, sonst schluckt das
Overlay den pointerdown und ein geblurrtes Board ist nicht verschiebbar. Der Rest des
Boards bleibt unter dem Overlay -> Klick dort enthuellt weiterhin (Phase-5-Review). */
position: relative;
z-index: 6;
} }
.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);
@@ -693,6 +740,43 @@ body.show-desc .bm-desc { display: block; }
border-radius: 4px; color: var(--text-muted); white-space: nowrap; border-radius: 4px; color: var(--text-muted); white-space: nowrap;
} }
/* ---- PAPIERKORB (Trash, v2.3) ---- */
.trash-list { padding: 4px 0; }
.trash-empty {
padding: 12px 18px; font-size: 12px; color: var(--text-muted);
}
/* Vertikale Karte pro Eintrag: Info ueber volle Breite, Aktionen rechtsbuendig darunter.
Verhindert das Zusammenquetschen im schmalen 380px-Settings-Panel. */
.trash-item {
display: flex; flex-direction: column; gap: 8px;
padding: 10px 18px;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.trash-item:last-child { border-bottom: none; }
.trash-item-info { min-width: 0; }
.trash-item-title {
display: block; font-size: 13px; color: var(--text-primary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.trash-item-meta {
display: block; font-size: 11px; color: var(--text-muted); margin-top: 3px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.trash-item-badge {
display: inline-block; vertical-align: middle;
font-size: 9px; padding: 2px 6px; border-radius: 4px;
background: rgba(255,255,255,0.06); border: 1px solid var(--border);
color: var(--text-muted); white-space: nowrap; margin-right: 6px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.trash-item-actions { display: flex; gap: 6px; justify-content: flex-end; }
/* Per-Eintrag-Buttons kompakt: das volle Uppercase-btn-danger ist pro Eintrag zu wuchtig. */
.trash-item-actions .btn-danger {
padding: 5px 10px; font-size: 11px; letter-spacing: 0.5px;
}
.trash-actions { justify-content: flex-end; }
.trash-actions.hidden { display: none; }
/* THEME PICKER */ /* THEME PICKER */
.theme-grid { .theme-grid {
display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; display: grid; grid-template-columns: 1fr 1fr 1fr 1fr;
@@ -844,14 +928,8 @@ body.show-desc .bm-desc { display: block; }
.modal-body { padding: 14px 16px; } .modal-body { padding: 14px 16px; }
.modal-footer { padding: 10px 16px 14px; display: flex; justify-content: flex-end; } .modal-footer { padding: 10px 16px 14px; display: flex; justify-content: flex-end; }
.board.dragging { opacity: 0.35; } /* Free-Move: das gezogene Board nach vorne heben (frueher Reorder-Opacity). */
.board.dragging { z-index: 50; cursor: grabbing; }
.board-placeholder {
border: 2px dashed var(--border-accent);
border-radius: var(--radius);
background: var(--accent-dim);
flex-shrink: 0;
}
} }
@@ -867,9 +945,10 @@ body.show-desc .bm-desc { display: block; }
.board.blurred .board-title { .board.blurred .board-title {
filter: blur(5px); filter: blur(5px);
} }
.board.blurred { /* HINWEIS: KEIN `.board.blurred { position: relative }` — .board ist im Free-Layout bereits
position: relative; position:absolute (= positionierter Containing-Block fuers Overlay). Ein relative hier liegt
} im @layer utilities und wuerde das absolute schlagen -> geblurrtes Board faellt aus seiner
freien Position + waere nicht mehr drag-bar (Phase-5-Review). */
.board-blur-overlay { .board-blur-overlay {
display: none; display: none;
position: absolute; inset: 0; z-index: 5; position: absolute; inset: 0; z-index: 5;
@@ -2334,6 +2413,35 @@ body.show-desc .bm-desc { display: block; }
.theme-modal-section .setting-row { .theme-modal-section .setting-row {
padding: 8px 0; padding: 8px 0;
} }
.bg-url-hint {
padding: 2px 0 6px;
line-height: 1.4;
}
.theme-card-custom-swatch {
width: 100%; height: 56px; border-radius: 8px;
border: 1.5px dashed var(--accent);
background: repeating-linear-gradient(45deg, rgba(255,255,255,0.03) 0 6px, rgba(255,255,255,0.06) 6px 12px);
}
.theme-builder-panel { padding: 10px 0 4px; }
.theme-builder-panel.hidden { display: none; }
.tb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 18px; }
.tb-picker { display: flex; align-items: center; gap: 9px; }
.tb-picker input[type="color"] {
width: 30px; height: 30px; padding: 0; border: 1px solid var(--border);
border-radius: 6px; background: none; cursor: pointer;
}
.tb-picker label { font-size: 12px; color: var(--text-secondary); }
.tb-contrast { margin-top: 12px; font-size: 12px; display: flex; align-items: center; gap: 8px; }
.tb-contrast .tb-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; }
.tb-contrast.good { color: #7bd88f; } .tb-contrast.good .tb-dot { background: #3fbf6f; }
.tb-contrast.ok { color: #e3c97a; } .tb-contrast.ok .tb-dot { background: #d8b24a; }
.tb-contrast.bad { color: #e58f8f; } .tb-contrast.bad .tb-dot { background: #d65c5c; }
.tb-foot { display: flex; justify-content: flex-end; margin-top: 10px; }
.tb-reset {
font-size: 11px; color: var(--text-secondary); background: none;
border: 1px solid var(--border); border-radius: 6px; padding: 5px 12px; cursor: pointer;
}
@media (max-width: 480px) { .tb-grid { grid-template-columns: 1fr; } }
/* ============================================ /* ============================================
ACCORDION SETTINGS ACCORDION SETTINGS
@@ -2354,6 +2462,78 @@ body.show-desc .bm-desc { display: block; }
.settings-section.open .section-content { .settings-section.open .section-content {
max-height: 800px; max-height: 800px;
} }
/* ---- COMMAND-PALETTE (Strg+K) ---- */
/* .palette-overlay erbt .dialog-overlay (Position/Backdrop/Fade/z-index:400). */
.palette-overlay {
align-items: flex-start;
}
.palette-box {
margin-top: 12vh;
width: 560px; max-width: 92vw;
background: rgba(10,9,20,0.98);
border: 1px solid var(--border);
border-radius: var(--radius);
backdrop-filter: blur(28px);
box-shadow: 0 20px 60px rgba(0,0,0,0.7);
transform: translateY(8px) scale(0.98);
transition: transform 0.2s;
overflow: hidden;
}
.dialog-overlay.active .palette-box { transform: translateY(0) scale(1); }
.palette-input {
width: 100%;
box-sizing: border-box;
padding: 16px 18px;
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 15px;
outline: none;
}
.palette-input::placeholder { color: var(--text-muted); }
.palette-list {
list-style: none;
margin: 0; padding: 6px;
max-height: 46vh;
overflow-y: auto;
}
.palette-option {
display: flex; flex-direction: column; gap: 2px;
padding: 9px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.palette-option[aria-selected="true"] {
background: var(--accent-dim);
}
.palette-option-title {
color: var(--text-primary);
font-size: 13px; font-weight: 500;
}
.palette-option-meta {
color: var(--text-muted);
font-size: 10px; letter-spacing: 0.3px;
}
.palette-empty {
list-style: none;
padding: 16px 12px;
color: var(--text-muted);
font-size: 13px; text-align: center;
}
.palette-live {
position: absolute;
width: 1px; height: 1px;
overflow: hidden; clip: rect(0 0 0 0);
white-space: nowrap;
}
.palette-hint {
padding: 8px 14px;
border-top: 1px solid var(--border);
color: var(--text-muted);
font-size: 10px; letter-spacing: 0.3px;
}
} }
/* ============================================ /* ============================================
@@ -2363,11 +2543,6 @@ body.show-desc .bm-desc { display: block; }
.hidden { display: none; } .hidden { display: none; }
.accent-text { color: var(--accent); } .accent-text { color: var(--accent); }
.dim { opacity: 0.4; } .dim { opacity: 0.4; }
.drag-ghost {
position: fixed; opacity: 0.75; pointer-events: none; z-index: 9999;
transform: rotate(1.5deg) scale(1.02);
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
}
.bm-item.drag-over { background: rgba(255,160,50,0.07); } .bm-item.drag-over { background: rgba(255,160,50,0.07); }
.bm-item.dragging-source { opacity: 0.4; } .bm-item.dragging-source { opacity: 0.4; }
.about-info-label-block { display: block; margin-bottom: 6px; } .about-info-label-block { display: block; margin-bottom: 6px; }
@@ -2413,7 +2588,13 @@ body.show-desc .bm-desc { display: block; }
} }
.btn-icon { padding: 6px 8px; gap: 0; } .btn-icon { padding: 6px 8px; gap: 0; }
.boards-wrapper { padding: 100px 16px 24px; gap: 10px; } .boards-wrapper { padding: 100px 16px 24px; gap: 10px; justify-content: center; }
/* Free-Layout-Reset: ab Tablet wieder gestapeltes Flex-Layout. Ungeschichtet ->
gewinnt ueber @layer components (.board position:absolute). pos/--board-x/y werden ignoriert. */
.board {
position: static;
left: auto; top: auto;
}
.settings-panel { width: 320px; } .settings-panel { width: 320px; }
.theme-grid { grid-template-columns: 1fr 1fr; } .theme-grid { grid-template-columns: 1fr 1fr; }
@@ -2444,7 +2625,11 @@ body.show-desc .bm-desc { display: block; }
align-items: stretch; align-items: stretch;
} }
.board { width: 100%; } .board {
width: 100%;
position: static;
left: auto; top: auto;
}
.settings-panel { width: 100%; } .settings-panel { width: 100%; }
.theme-grid { grid-template-columns: 1fr 1fr; gap: 6px; } .theme-grid { grid-template-columns: 1fr 1fr; gap: 6px; }
+106 -1
View File
@@ -6,8 +6,18 @@
async function init() { async function init() {
const savedBoards = await Store.get('boards'); const savedBoards = await Store.get('boards');
const savedSettings = await Store.get('settings'); const savedSettings = await Store.get('settings');
const savedTrash = await Store.get('trash');
boards = savedBoards ?? getDefaultBoards(); boards = savedBoards ?? getDefaultBoards();
trash = Array.isArray(savedTrash) ? savedTrash : [];
// Auto-Cleanup: Eintraege aelter als 30 Tage verwerfen (TRASH-02). Muss VOR
// renderBoards() laufen, damit der Papierkorb-Stand konsistent ist. Schreibt nur
// zurueck, wenn wirklich etwas entfernt wurde (kein unnoetiger Storage-Write).
const cutoff = Date.now() - TRASH_RETENTION_MS;
const beforeCount = trash.length;
trash = trash.filter(entry => entry && typeof entry.deletedAt === 'number' && Number.isFinite(entry.deletedAt) && entry.deletedAt >= cutoff);
if (trash.length !== beforeCount) await saveTrash();
if (savedSettings) Object.assign(settings, savedSettings); if (savedSettings) Object.assign(settings, savedSettings);
I18n.init(); I18n.init();
@@ -16,7 +26,11 @@ async function init() {
startClock(); startClock();
bindGlobalEvents(); bindGlobalEvents();
bindSettingsEvents(); bindSettingsEvents();
bindStorageSync();
bindBoardResizeReclamp(); // Boards bei Fenster-Verkleinerung wieder in den sichtbaren Bereich holen
await drainQuickSavePending(); // beim Start angesammelte Quick-Saves (kein Tab war offen) einlesen
initSearch(); initSearch();
initPalette();
await migrateSticky(); await migrateSticky();
await Notes.init(); await Notes.init();
await Calculator.init(); await Calculator.init();
@@ -105,7 +119,7 @@ async function checkBackupReminder() {
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : []; const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : []; const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : [];
const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : []; const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : [];
const data = { version: '2.2.0', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets }; const data = { version: '2.4.0', exported: new Date().toISOString(), boards, settings, trash, notes: notesData, calculator: calcHistory, timerPresets };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -220,4 +234,95 @@ function bindGlobalEvents() {
}); });
} }
// ---- QUICK-SAVE PENDING-QUEUE ----
// Der Background-Worker haengt Quick-Saves an den eigenen Store-Key 'quicksave_pending' an (er
// schreibt NIE boards). Diese Seite ist die einzige boards-Schreiberin und drained die Queue in die
// Inbox. Getrennte Schreib-Domaenen -> Worker und Seite koennen sich nicht im boards-Array
// gegenseitig ueberschreiben (Datensicherheit, Phase-4-Review-Blocker 2b).
let _drainBusy = false;
let _drainQueued = false; // ein waehrend eines laufenden Drains angefragter Drain wird nachgeholt
let _renderDeferredByDrag = false; // Drain hat den Render wegen eines laufenden Drags ausgelassen -> nach Drag-Ende nachholen
async function drainQuickSavePending() {
if (_drainBusy) { _drainQueued = true; return; }
_drainBusy = true;
try {
const pending = await Store.get('quicksave_pending');
if (Array.isArray(pending) && pending.length > 0) {
const drained = pending.slice();
const drainedIds = new Set(drained.map(e => e && e.id).filter(Boolean));
const inbox = await ensureInboxBoard(); // legt die Inbox an, falls noetig; gibt das Board zurueck
// Idempotenz gegen den Worker/Drain-Race auf 'quicksave_pending': jede eingespielte Inbox-
// Bookmark traegt die Pending-id ihres Ursprungs als srcId. Taucht ein bereits gedrainter
// Eintrag durch einen gleichzeitigen Worker-Append erneut in der Queue auf, wird er hier
// uebersprungen statt doppelt eingefuegt — kein Duplikat, und kein Verlust (boards-Write
// bleibt vor der Queue-Bereinigung, daher keine umgekehrte Verlustgefahr).
const seenSrc = new Set(inbox.bookmarks.map(b => b && b.srcId).filter(Boolean));
for (const e of drained) {
if (!e || !e.id || seenSrc.has(e.id)) continue; // schon eingespielt
if (typeof e.url !== 'string' || !e.url || !isSafeUrl(e.url)) continue; // leeres/unsicheres Protokoll verwerfen
const bm = normalizeBookmark({ title: e.title, url: e.url });
bm.srcId = e.id; // Herkunft fuer kuenftige Dedup
inbox.bookmarks.push(bm);
seenSrc.add(e.id);
}
await saveBoards();
// NUR die verarbeiteten Eintraege entfernen — ein gleichzeitiger Worker-Append bleibt erhalten.
const still = await Store.get('quicksave_pending');
const remaining = Array.isArray(still) ? still.filter(e => e && !drainedIds.has(e.id)) : [];
await Store.set('quicksave_pending', remaining);
// Render nur, wenn gerade KEIN Drag laeuft (renderBoards->replaceChildren wuerde ihn abreissen).
// Laeuft einer, den Render-Wunsch merken und nach Drag-Ende nachholen (drag.js ruft
// flushQuickSaveRenderIfDeferred), sonst bliebe der frisch gedrainte Quick-Save bis zu einem
// unabhaengigen Fremd-Render unsichtbar (Phase-6-Review).
if (document.querySelector('.board.dragging, .bm-item.dragging-source')) {
_renderDeferredByDrag = true;
} else {
renderBoards();
}
}
} catch (e) {
console.error('Quick-Save-Drain fehlgeschlagen:', e && e.message);
} finally {
_drainBusy = false;
}
// Kam waehrend des Drains ein weiterer Quick-Save an (onChanged wurde durch _drainBusy verworfen),
// jetzt nachholen. Der Eintrag war sicher in der Queue, nur noch nicht eingelesen.
if (_drainQueued) { _drainQueued = false; drainQuickSavePending(); }
}
// Wird von drag.js nach jedem Drag-Ende aufgerufen: einen waehrend des Drags ausgelassenen
// Quick-Save-Render nachholen. Idempotent — tut nichts, wenn kein Render aussteht.
function flushQuickSaveRenderIfDeferred() {
if (_renderDeferredByDrag) {
_renderDeferredByDrag = false;
renderBoards();
}
}
// Live-Sync (QS-03): ein offener NewTab drained die Queue, sobald der Worker etwas anhaengt.
function bindStorageSync() {
if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.onChanged) return;
chrome.storage.onChanged.addListener((changes, area) => {
// Nur auf die Quick-Save-Queue reagieren — 'boards' schreibt ausschliesslich diese Seite.
if (area !== 'local' || !changes.quicksave_pending) return;
drainQuickSavePending();
});
}
// Freies Layout (LAYOUT-04): Boards stehen absolut positioniert. Schrumpft das Fenster, koennen
// sie ganz aus dem sichtbaren Bereich rutschen. renderBoards() klemmt die --board-x/--board-y jedes
// Boards beim Aufbau gegen die aktuelle Viewport — ein simpler Re-Render holt sie also zurueck.
// Debounce (150ms), damit kontinuierliches Resizen nicht hunderte Renders ausloest. Waehrend eines
// aktiven Drags NICHT neu rendern: renderBoards->replaceChildren wuerde den laufenden Drag abreissen.
function bindBoardResizeReclamp() {
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (document.querySelector('.board.dragging, .bm-item.dragging-source')) return;
renderBoards();
}, 150);
});
}
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
+82
View File
@@ -0,0 +1,82 @@
/* =============================================
HELLION NEWTAB — background.js
Quick-Save Background fuer Chrome (Service-Worker)
UND Firefox (Event-Page). Kein DOM/window. Listener
synchron auf Top-Level. Geteilte Logik via importScripts.
============================================= */
// Geteiltes DOM-freies Helfer-Modul aus Phase 1: ensureInbox(boards), uid(), normalizeBookmark(...).
// Chrome-Service-Worker laedt es via importScripts. Firefox-Event-Page hat KEIN importScripts —
// dort kommt das Modul ueber background.scripts (manifest.firefox.json) in den Scope, ensureInbox
// ist dann schon definiert. Der Guard verhindert den ReferenceError in der Event-Page.
if (typeof importScripts === 'function' && typeof ensureInbox === 'undefined') {
importScripts('quicksave-core.js');
}
// chrome.storage.local-Lese-/Schreib-Helfer als Promises (kein Store-Modul im Worker,
// das ist DOM/Seiten-gebunden). Identisches Verhalten: get -> Wert oder null.
function bgGet(key) {
return new Promise(resolve => {
chrome.storage.local.get([key], r => resolve(r[key] ?? null));
});
}
function bgSet(key, value) {
return new Promise((resolve, reject) => {
chrome.storage.local.set({ [key]: value }, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve();
});
});
}
// Kurze Badge-Bestaetigung, dann automatisch wieder leeren. color optional (Default gruen).
function flashBadge(text, color) {
chrome.action.setBadgeText({ text });
// Hintergrundfarbe optional, ohne extra Permission moeglich.
if (chrome.action.setBadgeBackgroundColor) {
chrome.action.setBadgeBackgroundColor({ color: color || '#1f9d55' });
}
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2000);
}
// Interne/nicht speicherbare Seiten (Browser-UI, Extension-Seiten) — kein sinnvolles Bookmark.
const UNSAVEABLE_URL = /^(chrome|chrome-extension|about|edge|opera|moz-extension|brave|vivaldi|view-source|devtools):/i;
// Quick-Save: aktiven Tab in die Pending-Queue haengen — NICHT boards schreiben.
// Datensicherheit (Phase-4-Review 2b): boards schreibt ausschliesslich die NewTab-Seite. Der Worker
// haengt nur an 'quicksave_pending' an; die Seite drained die Queue in die Inbox. So koennen Worker
// und Seite sich nicht im boards-Array gegenseitig ueberschreiben (kein Lost-Update bestehender Daten).
async function quickSaveActiveTab() {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs && tabs[0];
if (!tab || !tab.url || UNSAVEABLE_URL.test(tab.url)) {
// Kein speicherbarer Tab: kurzer roter Marker (langer Text wird im Badge abgeschnitten).
flashBadge('×', '#c0392b');
return;
}
try {
// read-modify-write nur auf der EIGENEN Queue (bgGet/bgSet sind via quickSaveChain serialisiert).
const pending = (await bgGet('quicksave_pending')) ?? [];
pending.push({ id: uid(), title: tab.title || tab.url, url: tab.url });
await bgSet('quicksave_pending', pending);
flashBadge(chrome.i18n.getMessage('quickSaveBadge'));
} catch (e) {
// Quota o.ae.: Badge zeigt nichts Gruenes, Fehler in die Worker-Konsole.
console.error('Quick-Save fehlgeschlagen:', e.message);
}
}
// Quick-Saves serialisieren: zwei schnelle Tastendruecke (Key-Repeat) wuerden sonst parallel
// read-modify-write machen und sich gegenseitig ueberschreiben (lost update). Promise-Kette
// sorgt fuer sequentielle Ausfuehrung. Listener bleibt SYNCHRON auf Top-Level registriert.
let quickSaveChain = Promise.resolve();
chrome.commands.onCommand.addListener(command => {
if (command === 'quick-save') {
quickSaveChain = quickSaveChain.then(() => quickSaveActiveTab()).catch(e => console.error('Quick-Save:', e && e.message));
}
});
+109 -13
View File
@@ -46,6 +46,34 @@ 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 ----
// 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
// Wrapper-Padding (110px oben / 40px links) und der Board-Breite.
function ensureBoardPositions() {
const COL_W = 240 + 14; // --board-width (Desktop) + gap
const ROW_H = 220; // grober Board-Hoehen-Schaetzwert fuers Auto-Raster
const startX = 40, startY = 110;
const cols = Math.max(1, Math.floor((window.innerWidth - startX * 2 + 14) / COL_W));
let migrated = false;
boards.forEach((board, i) => {
if (board.pos && typeof board.pos.x === 'number' && typeof board.pos.y === 'number') return;
const col = i % cols;
const row = Math.floor(i / cols);
board.pos = { x: startX + col * COL_W, y: startY + row * ROW_H };
migrated = true;
});
return migrated;
}
// ---- RENDER ---- // ---- RENDER ----
function renderBoards() { function renderBoards() {
const wrapper = document.getElementById('boardsWrapper'); const wrapper = document.getElementById('boardsWrapper');
@@ -70,13 +98,29 @@ function renderBoards() {
return; return;
} }
boards.forEach(board => wrapper.appendChild(createBoardEl(board))); // Altbestand ohne pos migrieren (Auto-Raster), danach einmalig speichern.
const migrated = ensureBoardPositions();
boards.forEach(board => {
const el = createBoardEl(board);
wrapper.appendChild(el);
// Position als Custom-Property setzen (nicht inline left/top), damit der Mobil-@media-Reset
// sie ueberschreiben kann. Gegen den AKTUELLEN Viewport clampen, damit ein auf breiterem
// Fenster platziertes Board nie off-screen (und damit per Drag unerreichbar) rendert.
// board.pos bleibt unveraendert -> bei spaeterer Verbreiterung wird die Originalposition wieder erreicht.
const cx = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, board.pos.x));
const cy = Math.max(48, Math.min(window.innerHeight - el.offsetHeight, board.pos.y));
el.style.setProperty('--board-x', cx + 'px');
el.style.setProperty('--board-y', cy + 'px');
});
initBoardDragDrop(); initBoardDragDrop();
if (migrated) saveBoards();
} }
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
@@ -96,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');
@@ -106,12 +155,19 @@ function createBoardEl(board) {
btnRename.title = t('boards.rename'); btnRename.title = t('boards.rename');
btnRename.textContent = '\u270E'; btnRename.textContent = '\u270E';
const btnDelete = document.createElement('button'); // Das feste Inbox-Board (Quick-Save-Ziel) darf nicht geloescht werden \u2014 kein Delete-Button.
btnDelete.className = 'board-action-btn btn-delete-board'; const btnDelete = board.id === 'inbox' ? null : document.createElement('button');
btnDelete.title = t('boards.delete'); if (btnDelete) {
btnDelete.textContent = '\u2715'; btnDelete.className = 'board-action-btn btn-delete-board';
btnDelete.title = t('boards.delete');
btnDelete.textContent = '\u2715';
}
actions.append(btnBlur, btnRename, btnDelete); if (btnDelete) {
actions.append(btnLock, btnBlur, btnRename, btnDelete);
} else {
actions.append(btnLock, btnBlur, btnRename);
}
header.append(dragHandle, titleSpanHeader, actions); header.append(dragHandle, titleSpanHeader, actions);
// Blur-Overlay // Blur-Overlay
@@ -119,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;
@@ -144,15 +210,29 @@ function createBoardEl(board) {
}); });
}); });
btnDelete.addEventListener('click', async e => { if (btnDelete) btnDelete.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation();
const ok = await HellionDialog.confirm( const ok = await HellionDialog.confirm(
t('boards.delete_confirm', { title: board.title }), t('boards.delete_confirm', { title: board.title }),
{ type: 'danger', title: t('boards.delete_confirm.title'), confirmText: t('boards.delete') } { type: 'danger', title: t('boards.delete_confirm.title'), confirmText: t('boards.delete') }
); );
if (ok) { if (ok) {
boards = boards.filter(b => b.id !== board.id); // Ganzes board-Objekt (inkl. bookmarks UND blurred-Flag, CR-01) in den Papierkorb.
await saveBoards(); // 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(); renderBoards();
} }
}); });
@@ -254,12 +334,28 @@ function bindBoardListEvents(list, board) {
const bmItem = e.target.closest('.bm-item'); const bmItem = e.target.closest('.bm-item');
if (!bmItem) return; if (!bmItem) return;
// Delete-Button geklickt // 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')) { if (e.target.closest('.bm-delete')) {
e.stopPropagation(); e.stopPropagation();
const bmId = bmItem.dataset.bmId; const bmId = bmItem.dataset.bmId;
board.bookmarks = board.bookmarks.filter(b => b.id !== bmId); const removed = board.bookmarks.find(b => b.id === bmId);
await saveBoards(); 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(); renderBoards();
return; return;
} }
+78 -14
View File
@@ -24,14 +24,26 @@ function initDataButtons() {
} }
} }
/**
* Validiert eine freie Layout-Position (LAYOUT-04). Liefert { x, y } nur bei
* endlichen Zahlen, sonst null — dann gridded ensureBoardPositions das Board neu.
* Ohne das wuerde ein Import jede vom Nutzer gesetzte Board-Position verwerfen.
* @param {*} pos
* @returns {{x:number,y:number}|null}
*/
function safePos(pos) {
return pos && Number.isFinite(pos.x) && Number.isFinite(pos.y) ? { x: pos.x, y: pos.y } : null;
}
// Export (inkl. Notes) // Export (inkl. Notes)
btnExport.addEventListener('click', async () => { btnExport.addEventListener('click', async () => {
const widgetData = await Store.get('widgetStates'); const widgetData = await Store.get('widgetStates');
const data = { const data = {
version: '2.2.0', version: '2.4.0',
exported: new Date().toISOString(), exported: new Date().toISOString(),
boards, boards,
settings, settings,
trash,
notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : [], notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : [],
calculator: widgetData && widgetData.calculator ? widgetData.calculator.history || [] : [], calculator: widgetData && widgetData.calculator ? widgetData.calculator.history || [] : [],
timerPresets: widgetData && widgetData.timer ? widgetData.timer.presets || [] : [] timerPresets: widgetData && widgetData.timer ? widgetData.timer.presets || [] : []
@@ -55,19 +67,26 @@ function initDataButtons() {
if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format')); if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format'));
const validBoards = data.boards const validBoards = data.boards
.filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks)) .filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
.map(b => ({ .map(b => {
id: b.id || uid(), const board = {
title: String(b.title).slice(0, 100), id: b.id || uid(),
blurred: !!b.blurred, title: String(b.title).slice(0, 100),
bookmarks: b.bookmarks blurred: !!b.blurred,
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url)) locked: !!b.locked,
.map(bm => ({ bookmarks: b.bookmarks
id: bm.id || uid(), .filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
title: String(bm.title).slice(0, 200), .map(bm => ({
url: bm.url, id: bm.id || uid(),
desc: String(bm.desc || '').slice(0, 500) title: String(bm.title).slice(0, 200),
})) url: bm.url,
})); desc: String(bm.desc || '').slice(0, 500)
}))
};
// Freies Layout (LAYOUT-04): valide Position uebernehmen, sonst gridded ensureBoardPositions neu.
const pos = safePos(b.pos);
if (pos) board.pos = pos;
return board;
});
if (validBoards.length === 0) throw new Error(t('data.no_boards')); if (validBoards.length === 0) throw new Error(t('data.no_boards'));
const ok = await HellionDialog.confirm( const ok = await HellionDialog.confirm(
t('data.import_confirm', { count: validBoards.length }), t('data.import_confirm', { count: validBoards.length }),
@@ -78,6 +97,51 @@ function initDataButtons() {
await saveBoards(); await saveBoards();
renderBoards(); renderBoards();
// Papierkorb importieren (falls vorhanden) — defensiv validiert.
if (Array.isArray(data.trash) && data.trash.length > 0) {
const validTrash = data.trash
.filter(e => e && e.item && ['bookmark', 'board'].includes(e.type) && typeof e.deletedAt === 'number' && Number.isFinite(e.deletedAt))
.map(e => ({
type: e.type,
originBoardId: typeof e.originBoardId === 'string' ? e.originBoardId : null,
deletedAt: e.deletedAt,
item: e.type === 'board'
? {
id: e.item.id || uid(),
title: String(e.item.title || '').slice(0, 100),
blurred: !!e.item.blurred,
locked: !!e.item.locked,
// Position erhalten, damit ein wiederhergestelltes Board an seinem Platz landet.
...(safePos(e.item.pos) ? { pos: safePos(e.item.pos) } : {}),
bookmarks: Array.isArray(e.item.bookmarks)
? e.item.bookmarks
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
.map(bm => ({ id: bm.id || uid(), title: String(bm.title).slice(0, 200), url: bm.url, desc: String(bm.desc || '').slice(0, 500) }))
.slice(0, 500)
: []
}
: (isSafeUrl(e.item.url)
? { id: e.item.id || uid(), title: String(e.item.title || '').slice(0, 200), url: e.item.url, desc: String(e.item.desc || '').slice(0, 500) }
: null)
}))
.filter(e => e.item !== null);
if (validTrash.length > 0) {
// Lokale Eintraege sind die EINZIGE Kopie ihrer geloeschten Daten -> Vorrang. Importierte
// stammen aus einem Backup, das der Nutzer noch besitzt -> nachrangig. Daher: erst ALLE
// lokalen behalten (pushToTrash kappt sie bereits auf TRASH_MAX_ENTRIES), dann mit den
// NEUESTEN importierten bis zur Obergrenze auffuellen. Ein frischer Import verdraengt so
// keine aelteren lokalen Sole-Copies mehr (frueheres sort+slice(-N) konnte das, data-loss).
const room = Math.max(0, TRASH_MAX_ENTRIES - trash.length);
const keptImports = validTrash
.slice()
.sort((a, b) => b.deletedAt - a.deletedAt) // neueste Importe zuerst
.slice(0, room);
// Am Ende nach deletedAt aufsteigend fuer eine stabile Anzeige-Reihenfolge.
trash = [...trash, ...keptImports].sort((a, b) => a.deletedAt - b.deletedAt);
await saveTrash();
}
}
// Notes importieren (falls vorhanden) // Notes importieren (falls vorhanden)
let notesImported = 0; let notesImported = 0;
const existingWidgets = await Store.get('widgetStates') || {}; const existingWidgets = await Store.get('widgetStates') || {};
+72 -73
View File
@@ -5,95 +5,92 @@
Bookmarks: Reihenfolge innerhalb eines Boards Bookmarks: Reihenfolge innerhalb eines Boards
============================================= */ ============================================= */
// ---- BOARD DRAG (Pointer Events) ---- // ---- BOARD FREE-MOVE (Pointer Events) ----
// Neugebaut fuer v2.3 (frueher Reorder mit Ghost/Placeholder). Vorbild:
// widgets.js _initDrag — setPointerCapture, offX/offY, onMove mit Clamping
// gegen window.innerWidth/Height, onUp schreibt board.pos + saveBoards().
// Gebunden am .board-drag-handle, NICHT am ganzen .board, damit Bookmark-Drag,
// Klick-Delegation und Action-Buttons frei bleiben.
function initBoardDragDrop() { function initBoardDragDrop() {
const wrapper = document.getElementById('boardsWrapper'); const wrapper = document.getElementById('boardsWrapper');
let dragging = null;
let placeholder = null;
function getInsertTarget(clientX, clientY) {
const boardEls = Array.from(wrapper.querySelectorAll('.board:not(.dragging)'));
for (const b of boardEls) {
const r = b.getBoundingClientRect();
if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) {
return { el: b, before: clientX < r.left + r.width / 2 };
}
}
return null;
}
wrapper.querySelectorAll('.board').forEach(boardEl => { wrapper.querySelectorAll('.board').forEach(boardEl => {
const handle = boardEl.querySelector('.board-drag-handle'); const handle = boardEl.querySelector('.board-drag-handle');
if (!handle) return; if (!handle) return;
handle.style.cursor = 'grab'; handle.addEventListener('pointerdown', function onDown(e) {
// Auf Mobil ist .board position:static (Stapel) -> kein Free-Move.
handle.addEventListener('pointerdown', e => { 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);
handle.style.cursor = 'grabbing'; // .board.dragging hebt das Board per CSS nach vorne (z-index) UND ist das Signal fuer den
// Live-Sync-Guard in app.js (bindStorageSync verwirft ein onChanged-Re-Render, das diesen
const rect = boardEl.getBoundingClientRect(); // Drag sonst abreissen wuerde). Der Guard prueft genau diese Klasse (Phase-4-Review 2a).
// Ghost
const ghost = boardEl.cloneNode(true);
ghost.className += ' drag-ghost';
ghost.style.left = rect.left + 'px';
ghost.style.top = rect.top + 'px';
ghost.style.width = rect.width + 'px';
ghost.style.height = rect.height + 'px';
document.body.appendChild(ghost);
// Placeholder
placeholder = document.createElement('div');
placeholder.className = 'board-placeholder';
placeholder.style.cssText = `width:${rect.width}px; height:${rect.height}px;`;
boardEl.parentNode.insertBefore(placeholder, boardEl);
boardEl.classList.add('dragging'); boardEl.classList.add('dragging');
dragging = { el: boardEl, ghost, const rect = boardEl.getBoundingClientRect();
offsetX: e.clientX - rect.left, const offX = e.clientX - rect.left;
offsetY: e.clientY - rect.top const offY = e.clientY - rect.top;
}; const startCX = e.clientX, startCY = e.clientY;
}); // Erst eine echte Bewegung (> 3px) zaehlt als Drag. Ein reiner Klick/Tap auf den Handle darf
// board.pos NICHT ueberschreiben: renderBoards() schreibt in --board-x/y den gegen die Viewport
// GECLAMPTEN Wert, board.pos bleibt absichtlich der wahre (evtl. off-screen) Wert. onUp liest
// --board-x/y zurueck — bei einem No-Move-Klick waere das der Clamp und wuerde die wahre
// Position zerstoeren (Phase-5-Review, HIGH/data-loss).
let moved = false;
handle.addEventListener('pointermove', e => { function onMove(ev) {
if (!dragging || dragging.el !== boardEl) return; if (Math.abs(ev.clientX - startCX) > 3 || Math.abs(ev.clientY - startCY) > 3) moved = true;
e.preventDefault(); const maxX = window.innerWidth - boardEl.offsetWidth;
dragging.ghost.style.left = (e.clientX - dragging.offsetX) + 'px'; const maxY = window.innerHeight - boardEl.offsetHeight;
dragging.ghost.style.top = (e.clientY - dragging.offsetY) + 'px'; const x = Math.max(0, Math.min(maxX, ev.clientX - offX));
const y = Math.max(48, Math.min(maxY, ev.clientY - offY)); // 48px = Header-Hoehe
const target = getInsertTarget(e.clientX, e.clientY); boardEl.style.setProperty('--board-x', x + 'px');
if (target && target.el !== boardEl) { boardEl.style.setProperty('--board-y', y + 'px');
target.before
? target.el.parentNode.insertBefore(placeholder, target.el)
: target.el.parentNode.insertBefore(placeholder, target.el.nextSibling);
} }
});
handle.addEventListener('pointerup', async () => { // Gemeinsames Aufraeumen: Pointer-Capture freigeben, ALLE Listener entfernen,
if (!dragging || dragging.el !== boardEl) return; // .board.dragging entfernen. MUSS auch im Cancel-Pfad laufen — sonst klebt die Klasse
handle.style.cursor = 'grab'; // und der app.js-Sync-Guard unterdrueckt dauerhaft Quick-Save-Renders (Phase-5-Review).
placeholder.parentNode.insertBefore(boardEl, placeholder); function cleanup() {
placeholder.remove(); placeholder = null; try { handle.releasePointerCapture(e.pointerId); } catch (_) { /* schon freigegeben */ }
boardEl.classList.remove('dragging'); handle.removeEventListener('pointermove', onMove);
dragging.ghost.remove(); handle.removeEventListener('pointerup', onUp);
dragging = null; handle.removeEventListener('pointercancel', onCancel);
boardEl.classList.remove('dragging');
}
// Neue Reihenfolge aus DOM ablesen async function onUp() {
const newOrder = Array.from(wrapper.querySelectorAll('.board')) cleanup();
.map(el => el.dataset.boardId).filter(Boolean); // Nur bei echtem Verschieben persistieren — sonst board.pos unangetastet lassen.
boards.sort((a, b) => newOrder.indexOf(a.id) - newOrder.indexOf(b.id)); if (moved) {
await saveBoards(); const id = boardEl.dataset.boardId;
}); const board = boards.find(b => b.id === id);
if (board) {
board.pos = {
x: parseFloat(boardEl.style.getPropertyValue('--board-x')),
y: parseFloat(boardEl.style.getPropertyValue('--board-y'))
};
await saveBoards();
}
}
// Einen waehrend des Drags ausgelassenen Quick-Save-Render nachholen (app.js).
if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
}
handle.addEventListener('pointercancel', () => { // pointercancel feuert STATT pointerup bei Touch-Interrupt, Browser-Geste oder wenn das
if (!dragging) return; // captured Element aus dem DOM faellt -> nur aufraeumen (cleanup), pos NICHT ueberschreiben.
dragging.ghost.remove(); function onCancel() {
if (placeholder) { placeholder.remove(); placeholder = null; } cleanup();
boardEl.classList.remove('dragging'); if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
dragging = null; }
handle.style.cursor = 'grab';
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onCancel);
}); });
}); });
} }
@@ -113,6 +110,8 @@ function initBookmarkDragDrop(listEl, board) {
listEl.addEventListener('dragend', e => { listEl.addEventListener('dragend', e => {
const item = e.target.closest('.bm-item'); const item = e.target.closest('.bm-item');
if (item) item.classList.remove('dragging-source'); if (item) item.classList.remove('dragging-source');
// Blieb ein Quick-Save-Render waehrend des Bookmark-Drags aus (Drop ausgeblieben), jetzt nachholen.
if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
}); });
listEl.addEventListener('dragover', e => { listEl.addEventListener('dragover', e => {
+110 -10
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?',
@@ -30,6 +32,26 @@ const STRINGS = {
'boards.add_link': ' Link hinzufügen', 'boards.add_link': ' Link hinzufügen',
'boards.remove_bookmark': 'Entfernen', 'boards.remove_bookmark': 'Entfernen',
// Papierkorb (Trash)
'trash.section': 'PAPIERKORB',
'trash.empty': 'Der Papierkorb ist leer.',
'trash.deleted_at': 'Gelöscht am {date}',
'trash.type.bookmark': 'Lesezeichen',
'trash.type.board': 'Board',
'trash.from_board': 'aus „{board}"',
'trash.from_board_unknown': 'Ursprungs-Board gelöscht',
'trash.restore': 'Wiederherstellen',
'trash.restore_title': 'Wiederherstellen',
'trash.delete_forever': 'Endgültig löschen',
'trash.delete_forever_title': 'Endgültig löschen',
'trash.empty_btn': 'Papierkorb leeren',
'trash.empty_confirm': 'Alle {count} Einträge endgültig löschen? Das kann nicht rückgängig gemacht werden.',
'trash.empty_confirm.title': 'Papierkorb leeren',
'trash.delete_forever_confirm': 'Diesen Eintrag endgültig löschen? Das kann nicht rückgängig gemacht werden.',
'trash.delete_forever_confirm.title': 'Endgültig löschen',
'trash.restored_to_inbox': 'Lesezeichen in die Inbox wiederhergestellt (Ursprungs-Board existiert nicht mehr).',
'trash.restored_to_inbox.title': 'Wiederhergestellt',
// Onboarding // Onboarding
'onboarding.skip': 'Überspringen', 'onboarding.skip': 'Überspringen',
'onboarding.back': 'Zurück', 'onboarding.back': 'Zurück',
@@ -44,8 +66,8 @@ const STRINGS = {
'onboarding.s2.f2': 'Importiere Browser-Lesezeichen über den „Import" Button im Header', 'onboarding.s2.f2': 'Importiere Browser-Lesezeichen über den „Import" Button im Header',
'onboarding.s2.f3': 'Drag & Drop zum Umsortieren von Boards und Links', 'onboarding.s2.f3': 'Drag & Drop zum Umsortieren von Boards und Links',
'onboarding.s2.f4': 'Blur-Modus für private Boards (🔒 Icon)', 'onboarding.s2.f4': 'Blur-Modus für private Boards (🔒 Icon)',
'onboarding.s3.title': '11 handgefertigte Themes', 'onboarding.s3.title': 'Handgefertigte Themes + dein eigenes',
'onboarding.s3.text': 'Klicke auf den „Theme" Button im Header um dein Theme zu wählen. Jedes hat seinen eigenen Stil und Farbpalette.', 'onboarding.s3.text': 'Klicke auf den „Theme" Button im Header, um ein Theme zu wählen oder dir ein eigenes zu bauen. Jedes hat seinen eigenen Stil.',
'onboarding.s4.title': 'Widget-Toolbar', 'onboarding.s4.title': 'Widget-Toolbar',
'onboarding.s4.f1': 'Die schwebenden Buttons rechts öffnen Widgets', 'onboarding.s4.f1': 'Die schwebenden Buttons rechts öffnen Widgets',
'onboarding.s4.f2': 'Notes und Checklisten für schnelle Notizen', 'onboarding.s4.f2': 'Notes und Checklisten für schnelle Notizen',
@@ -60,6 +82,8 @@ const STRINGS = {
'onboarding.tradecenter_desc': 'Trade Center für Star Citizen', 'onboarding.tradecenter_desc': 'Trade Center für Star Citizen',
'onboarding.s7.title': 'Bereit!', 'onboarding.s7.title': 'Bereit!',
'onboarding.s7.text': 'Erstelle dein erstes Board mit „+ Board" oder importiere deine Browser-Lesezeichen über den Import-Button im Header. Viel Spaß!', 'onboarding.s7.text': 'Erstelle dein erstes Board mit „+ Board" oder importiere deine Browser-Lesezeichen über den Import-Button im Header. Viel Spaß!',
'onboarding.palette.title': 'Blitzschnell finden mit Strg+K',
'onboarding.palette.text': 'Drück jederzeit Strg+K, um die Befehls-Palette zu öffnen und alle Lesezeichen und Boards sofort zu durchsuchen. Mit ↑↓ navigieren, Enter öffnet, Esc schließt.',
// Notes // Notes
'notes.limit_message': 'Maximale Anzahl erreicht! Du kannst maximal {max} Notes gleichzeitig haben. Lösche eine bestehende Note um eine neue zu erstellen.', 'notes.limit_message': 'Maximale Anzahl erreicht! Du kannst maximal {max} Notes gleichzeitig haben. Lösche eine bestehende Note um eine neue zu erstellen.',
@@ -361,7 +385,9 @@ const STRINGS = {
'settings.visible_count': 'Sichtbare Bookmarks', 'settings.visible_count': 'Sichtbare Bookmarks',
'settings.visible_count.desc': 'Anzahl vor dem Ausblenden', 'settings.visible_count.desc': 'Anzahl vor dem Ausblenden',
'settings.bg_url': 'Bild-URL', 'settings.bg_url': 'Bild-URL',
'settings.bg_url.desc': 'Eigenes Hintergrundbild per URL', 'settings.bg_url.desc': 'Eigenes Bild per https-URL oder lokalem Upload',
'settings.bg_url.placeholder': 'https://… oder leer für Standard',
'settings.bg_url.privacy_hint': 'Hinweis: Ein per URL eingebundenes Bild wird bei jedem Öffnen vom fremden Server geladen.',
'settings.bg_change': 'Ändern', 'settings.bg_change': 'Ändern',
'settings.bg_apply': 'Übernehmen', 'settings.bg_apply': 'Übernehmen',
'settings.bg_upload': 'Datei hochladen', 'settings.bg_upload': 'Datei hochladen',
@@ -372,7 +398,7 @@ const STRINGS = {
'settings.onboarding_btn': 'Start', 'settings.onboarding_btn': 'Start',
'settings.reset_btn': 'Reset', 'settings.reset_btn': 'Reset',
'settings.bg_upload_btn': 'Upload', 'settings.bg_upload_btn': 'Upload',
'settings.bg_invalid_url': 'Nur lokale Bilder (Upload) sind als Hintergrund erlaubt.', 'settings.bg_invalid_url': 'Nur https-URLs oder lokale Bilder (Upload) sind als Hintergrund erlaubt.',
'settings.bg_invalid_url.title': 'Ungültige URL', 'settings.bg_invalid_url.title': 'Ungültige URL',
// Modals // Modals
@@ -399,6 +425,18 @@ const STRINGS = {
'theme.card.satisfactory': 'Theme Satisfactory wählen', 'theme.card.satisfactory': 'Theme Satisfactory wählen',
'theme.card.avorion': 'Theme Avorion wählen', 'theme.card.avorion': 'Theme Avorion wählen',
'theme.card.hellion_stealth': 'Theme Hellion Stealth wählen', 'theme.card.hellion_stealth': 'Theme Hellion Stealth wählen',
'theme.card.custom': 'Eigenes Theme wählen',
'theme.builder.title': 'Eigenes',
'theme.builder.accent': 'Akzent',
'theme.builder.bg': 'Hintergrund',
'theme.builder.board': 'Board-Fläche',
'theme.builder.text': 'Text primär',
'theme.builder.text_secondary': 'Text sekundär',
'theme.builder.text_muted': 'Text gedämpft',
'theme.builder.reset': 'Zurücksetzen',
'theme.builder.contrast_good': 'Gut lesbar',
'theme.builder.contrast_ok': 'Grenzwertig',
'theme.builder.contrast_bad': 'Schwer lesbar',
'toolbar.label': 'Widget-Werkzeugleiste', 'toolbar.label': 'Widget-Werkzeugleiste',
// About // About
@@ -426,7 +464,19 @@ const STRINGS = {
'toolbar.calculator': 'Taschenrechner', 'toolbar.calculator': 'Taschenrechner',
'toolbar.timer': 'Timer', 'toolbar.timer': 'Timer',
'toolbar.imageref': 'Bild-Referenz', 'toolbar.imageref': 'Bild-Referenz',
'toolbar.notebook': 'Alle Notes' 'toolbar.notebook': 'Alle Notes',
// Command-Palette
'palette.placeholder': 'Bookmarks & Boards durchsuchen…',
'palette.aria_label': 'Bookmark-Suche',
'palette.list_label': 'Suchergebnisse',
'palette.no_results': 'Keine Treffer',
'palette.hint': 'Strg+K zum Öffnen · ↑↓ zum Navigieren · Enter öffnet · Esc schließt',
'palette.count': '{count} Treffer',
'palette.count_one': '1 Treffer',
'palette.board_prefix': 'Board:',
'palette.discover': 'Lesezeichen & Boards durchsuchen (Strg+K)',
'palette.discover_label': 'Suchen'
}, },
en: { en: {
@@ -446,6 +496,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}"?',
@@ -455,6 +507,26 @@ const STRINGS = {
'boards.add_link': ' Add link', 'boards.add_link': ' Add link',
'boards.remove_bookmark': 'Remove', 'boards.remove_bookmark': 'Remove',
// Trash
'trash.section': 'TRASH',
'trash.empty': 'The trash is empty.',
'trash.deleted_at': 'Deleted on {date}',
'trash.type.bookmark': 'Bookmark',
'trash.type.board': 'Board',
'trash.from_board': 'from "{board}"',
'trash.from_board_unknown': 'Origin board deleted',
'trash.restore': 'Restore',
'trash.restore_title': 'Restore',
'trash.delete_forever': 'Delete forever',
'trash.delete_forever_title': 'Delete forever',
'trash.empty_btn': 'Empty trash',
'trash.empty_confirm': 'Permanently delete all {count} entries? This cannot be undone.',
'trash.empty_confirm.title': 'Empty trash',
'trash.delete_forever_confirm': 'Permanently delete this entry? This cannot be undone.',
'trash.delete_forever_confirm.title': 'Delete forever',
'trash.restored_to_inbox': 'Bookmark restored to the inbox (origin board no longer exists).',
'trash.restored_to_inbox.title': 'Restored',
// Onboarding // Onboarding
'onboarding.skip': 'Skip', 'onboarding.skip': 'Skip',
'onboarding.back': 'Back', 'onboarding.back': 'Back',
@@ -469,8 +541,8 @@ const STRINGS = {
'onboarding.s2.f2': 'Import browser bookmarks via the "Import" button in the header', 'onboarding.s2.f2': 'Import browser bookmarks via the "Import" button in the header',
'onboarding.s2.f3': 'Drag & drop to reorder boards and links', 'onboarding.s2.f3': 'Drag & drop to reorder boards and links',
'onboarding.s2.f4': 'Blur mode for private boards (🔒 icon)', 'onboarding.s2.f4': 'Blur mode for private boards (🔒 icon)',
'onboarding.s3.title': '11 handcrafted themes', 'onboarding.s3.title': 'Hand-crafted themes + your own',
'onboarding.s3.text': 'Click the "Theme" button in the header to choose your theme. Each has its own style and color palette.', 'onboarding.s3.text': 'Click the "Theme" button in the header to choose a theme or build your own. Each has its own style.',
'onboarding.s4.title': 'Widget Toolbar', 'onboarding.s4.title': 'Widget Toolbar',
'onboarding.s4.f1': 'The floating buttons on the right open widgets', 'onboarding.s4.f1': 'The floating buttons on the right open widgets',
'onboarding.s4.f2': 'Notes and checklists for quick notes', 'onboarding.s4.f2': 'Notes and checklists for quick notes',
@@ -485,6 +557,8 @@ const STRINGS = {
'onboarding.tradecenter_desc': 'Trade Center for Star Citizen', 'onboarding.tradecenter_desc': 'Trade Center for Star Citizen',
'onboarding.s7.title': 'Ready!', 'onboarding.s7.title': 'Ready!',
'onboarding.s7.text': 'Create your first board with "+ Board" or import your browser bookmarks via the Import button in the header. Have fun!', 'onboarding.s7.text': 'Create your first board with "+ Board" or import your browser bookmarks via the Import button in the header. Have fun!',
'onboarding.palette.title': 'Find anything with Ctrl+K',
'onboarding.palette.text': 'Press Ctrl+K anytime to open the command palette and instantly search all your bookmarks and boards. Navigate with ↑↓, Enter opens, Esc closes.',
// Notes // Notes
'notes.limit_message': 'Maximum reached! You can have at most {max} notes at the same time. Delete an existing note to create a new one.', 'notes.limit_message': 'Maximum reached! You can have at most {max} notes at the same time. Delete an existing note to create a new one.',
@@ -786,7 +860,9 @@ const STRINGS = {
'settings.visible_count': 'Visible bookmarks', 'settings.visible_count': 'Visible bookmarks',
'settings.visible_count.desc': 'Number before hiding', 'settings.visible_count.desc': 'Number before hiding',
'settings.bg_url': 'Image URL', 'settings.bg_url': 'Image URL',
'settings.bg_url.desc': 'Custom background image via URL', 'settings.bg_url.desc': 'Custom image via https URL or local upload',
'settings.bg_url.placeholder': 'https://… or empty for default',
'settings.bg_url.privacy_hint': 'Note: an image loaded via URL is fetched from the remote server every time you open a tab.',
'settings.bg_change': 'Change', 'settings.bg_change': 'Change',
'settings.bg_apply': 'Apply', 'settings.bg_apply': 'Apply',
'settings.bg_upload': 'Upload file', 'settings.bg_upload': 'Upload file',
@@ -797,7 +873,7 @@ const STRINGS = {
'settings.onboarding_btn': 'Start', 'settings.onboarding_btn': 'Start',
'settings.reset_btn': 'Reset', 'settings.reset_btn': 'Reset',
'settings.bg_upload_btn': 'Upload', 'settings.bg_upload_btn': 'Upload',
'settings.bg_invalid_url': 'Only local images (upload) are allowed as background.', 'settings.bg_invalid_url': 'Only https URLs or local images (upload) are allowed as background.',
'settings.bg_invalid_url.title': 'Invalid URL', 'settings.bg_invalid_url.title': 'Invalid URL',
// Modals // Modals
@@ -824,6 +900,18 @@ const STRINGS = {
'theme.card.satisfactory': 'Select Satisfactory theme', 'theme.card.satisfactory': 'Select Satisfactory theme',
'theme.card.avorion': 'Select Avorion theme', 'theme.card.avorion': 'Select Avorion theme',
'theme.card.hellion_stealth': 'Select Hellion Stealth theme', 'theme.card.hellion_stealth': 'Select Hellion Stealth theme',
'theme.card.custom': 'Select custom theme',
'theme.builder.title': 'Custom',
'theme.builder.accent': 'Accent',
'theme.builder.bg': 'Background',
'theme.builder.board': 'Board surface',
'theme.builder.text': 'Text primary',
'theme.builder.text_secondary': 'Text secondary',
'theme.builder.text_muted': 'Text muted',
'theme.builder.reset': 'Reset',
'theme.builder.contrast_good': 'Good contrast',
'theme.builder.contrast_ok': 'Borderline',
'theme.builder.contrast_bad': 'Hard to read',
'toolbar.label': 'Widget toolbar', 'toolbar.label': 'Widget toolbar',
// About // About
@@ -851,7 +939,19 @@ const STRINGS = {
'toolbar.calculator': 'Calculator', 'toolbar.calculator': 'Calculator',
'toolbar.timer': 'Timer', 'toolbar.timer': 'Timer',
'toolbar.imageref': 'Image reference', 'toolbar.imageref': 'Image reference',
'toolbar.notebook': 'All notes' 'toolbar.notebook': 'All notes',
// Command palette
'palette.placeholder': 'Search bookmarks & boards…',
'palette.aria_label': 'Bookmark search',
'palette.list_label': 'Search results',
'palette.no_results': 'No results',
'palette.hint': 'Ctrl+K to open · ↑↓ to navigate · Enter opens · Esc closes',
'palette.count': '{count} results',
'palette.count_one': '1 result',
'palette.board_prefix': 'Board:',
'palette.discover': 'Search bookmarks & boards (Ctrl+K)',
'palette.discover_label': 'Search'
} }
}; };
+5
View File
@@ -39,6 +39,11 @@ const Onboarding = {
textKey: 'onboarding.s6.text', textKey: 'onboarding.s6.text',
interactive: 'gaming-board' interactive: 'gaming-board'
}, },
{
hero: '\uD83D\uDD0D',
titleKey: 'onboarding.palette.title',
textKey: 'onboarding.palette.text'
},
{ {
hero: '\uD83D\uDE80', hero: '\uD83D\uDE80',
titleKey: 'onboarding.s7.title', titleKey: 'onboarding.s7.title',
+56
View File
@@ -32,3 +32,59 @@ chrome.tabs.onActivated.addListener((activeInfo) => {
if (tab) forceRedirect(tab.id, tab.url); if (tab) forceRedirect(tab.id, tab.url);
}); });
}); });
// ---- QUICK SAVE (v2.3, additiv — Redirect oben unberuehrt) ----
// Geteiltes Helfer-Modul: ensureInbox(boards), uid(), normalizeBookmark(...).
// Pfad ist relativ zu DIESER Datei (src/js/opera/), daher ../quicksave-core.js.
importScripts('../quicksave-core.js');
// Interne/nicht speicherbare Seiten (Browser-UI, Extension-Seiten) — kein sinnvolles Bookmark.
const UNSAVEABLE_URL = /^(chrome|chrome-extension|about|edge|opera|moz-extension|brave|vivaldi|view-source|devtools):/i;
// Re-Entry-Schutz: ein zweiter Quick-Save waehrend der erste laeuft wuerde read-modify-write
// rennen (lost update). Bei aktivem Save den zweiten Druck verwerfen.
let qsBusy = false;
function quickSaveActiveTab() {
if (qsBusy) return;
qsBusy = true;
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tab = tabs && tabs[0];
if (!tab || !tab.url || UNSAVEABLE_URL.test(tab.url)) {
// Kein speicherbarer Tab: kurzer roter Marker (langer Text wird im Badge abgeschnitten).
chrome.action.setBadgeText({ text: '×' });
if (chrome.action.setBadgeBackgroundColor) {
chrome.action.setBadgeBackgroundColor({ color: '#c0392b' });
}
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2000);
qsBusy = false;
return;
}
// Datensicher: NICHT boards schreiben — nur an die Pending-Queue anhaengen (die Seite
// drained sie in die Inbox). So kann der Worker das boards-Array nicht clobbern (Review 2b).
chrome.storage.local.get(['quicksave_pending'], (r) => {
const pending = Array.isArray(r.quicksave_pending) ? r.quicksave_pending : [];
pending.push({ id: uid(), title: tab.title || tab.url, url: tab.url });
chrome.storage.local.set({ quicksave_pending: pending }, () => {
if (chrome.runtime.lastError) {
console.error('Quick-Save fehlgeschlagen:', chrome.runtime.lastError.message);
qsBusy = false;
return;
}
chrome.action.setBadgeText({ text: chrome.i18n.getMessage('quickSaveBadge') });
if (chrome.action.setBadgeBackgroundColor) {
chrome.action.setBadgeBackgroundColor({ color: '#1f9d55' });
}
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2000);
qsBusy = false;
});
});
});
}
// onCommand SYNCHRON auf Top-Level (additiv neben den Redirect-Listenern).
chrome.commands.onCommand.addListener((command) => {
if (command === 'quick-save') {
quickSaveActiveTab();
}
});
+240
View File
@@ -0,0 +1,240 @@
/* =============================================
HELLION NEWTAB — palette.js
Command-Palette (Strg+K): read-only Suche ueber Boards/Bookmarks
============================================= */
// Reine init-Funktion ohne Top-Level-Seiteneffekt (Hausmuster wie initSearch).
// Wird aus app.js init() nach initSearch() aufgerufen.
function initPalette() {
let overlay = null; // aktives Overlay-Element oder null (= geschlossen)
let prevFocus = null; // Fokus-Rueckgabeziel
let keyHandler = null; // dokument-weiter Handler im offenen Zustand
let results = []; // aktuelle Trefferliste [{ title, url, boardName }]
let activeIndex = -1; // aktiver Listeneintrag fuer aria-activedescendant
// ---- Open-Guard: kein Strg+K wenn ein anderes Overlay offen ist ----
// Deckt Settings (.panel-overlay), Theme/Add-Board/Add-Bookmark/Rename
// (.modal-overlay) sowie HellionDialog UND Onboarding (.dialog-overlay) ab.
function isBlocked() {
// .palette-overlay ausklammern: das eigene (beim Schliessen noch deferred .active
// tragende) Overlay darf den Reopen-Guard nicht selbst blockieren (Self-Block-Race
// beim Toggle-Spam, da close() .active erst in withViewTransition entfernt).
return !!document.querySelector(
'.panel-overlay.active, .modal-overlay.active, .dialog-overlay.active:not(.palette-overlay)'
);
}
// ---- Trefferquelle: flach ueber alle Boards/Bookmarks ----
// Read-only auf dem globalen boards-Array. Match auf Titel, URL, Board-Name.
function search(query) {
const q = query.trim().toLowerCase();
if (!q) return [];
const out = [];
for (const board of boards) {
const boardName = board.title || '';
const boardMatch = boardName.toLowerCase().includes(q);
for (const bm of (board.bookmarks || [])) {
const title = bm.title || '';
const url = bm.url || '';
if (
title.toLowerCase().includes(q) ||
url.toLowerCase().includes(q) ||
boardMatch
) {
out.push({ title, url, boardName });
}
}
}
return out;
}
// ---- Treffer oeffnen (wie boards.js:270) ----
function openResult(item) {
if (!item || !item.url) return;
// Sicherheit: nur sichere Protokolle oeffnen. Verhindert javascript:/data:-URLs aus
// importierten Bookmarks (XSS im Extension-Origin, besonders bei _self). http/https/ftp only.
let safe = false;
try { safe = ['http:', 'https:', 'ftp:'].includes(new URL(item.url).protocol); } catch (e) { /* ungueltige URL */ }
if (!safe) { close(); return; }
window.open(item.url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
close();
}
// ---- Listbox neu rendern ----
function renderList(listEl, liveEl) {
listEl.textContent = '';
activeIndex = results.length ? 0 : -1;
if (results.length === 0) {
// Kein role="option": der Leerzustand ist keine auswaehlbare Option, sondern eine
// Statuszeile. Die Ansage uebernimmt die aria-live-Region (liveEl) unten.
const empty = document.createElement('li');
empty.className = 'palette-empty';
empty.setAttribute('role', 'presentation');
empty.textContent = t('palette.no_results');
listEl.appendChild(empty);
liveEl.textContent = t('palette.no_results');
return;
}
results.forEach((item, i) => {
const li = document.createElement('li');
li.className = 'palette-option';
li.id = 'palette-opt-' + i;
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
const titleSpan = document.createElement('span');
titleSpan.className = 'palette-option-title';
titleSpan.textContent = item.title;
const metaSpan = document.createElement('span');
metaSpan.className = 'palette-option-meta';
metaSpan.textContent = t('palette.board_prefix') + ' ' + item.boardName;
li.append(titleSpan, metaSpan);
// Pointer-Auswahl: Klick oeffnet, Hover markiert
li.addEventListener('click', () => openResult(item));
li.addEventListener('mousemove', () => setActive(listEl, i));
listEl.appendChild(li);
});
const count = results.length;
liveEl.textContent = count === 1 ? t('palette.count_one') : t('palette.count', { count });
}
// ---- aktiven Eintrag setzen (aria-activedescendant + aria-selected) ----
function setActive(listEl, idx) {
// Guard: close() nullt overlay synchron, das DOM-Removal laeuft aber deferred in
// withViewTransition. In diesem Frame-Fenster kann ein mousemove auf einem noch
// lebenden Treffer-<li> setActive() ausloesen -> ohne diesen Guard Null-Deref auf overlay.
if (!overlay) return;
const options = listEl.querySelectorAll('.palette-option');
if (options.length === 0) return;
activeIndex = Math.max(0, Math.min(idx, options.length - 1));
options.forEach((opt, i) => {
opt.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false');
});
const input = overlay.querySelector('.palette-input');
input.setAttribute('aria-activedescendant', options[activeIndex].id);
options[activeIndex].scrollIntoView({ block: 'nearest' });
}
// ---- schliessen: dialog.js-Cleanup-Muster (remove Listener -> Transition -> Fokus) ----
function close() {
if (!overlay) return;
document.removeEventListener('keydown', keyHandler);
const el = overlay;
overlay = null;
keyHandler = null;
withViewTransition(() => {
el.classList.remove('active');
el.remove();
});
if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus();
prevFocus = null;
}
// ---- oeffnen: Overlay nach dialog.js-Muster aufbauen ----
function open() {
if (overlay) return;
prevFocus = document.activeElement;
overlay = document.createElement('div');
overlay.className = 'dialog-overlay palette-overlay';
const box = document.createElement('div');
box.className = 'palette-box';
box.setAttribute('role', 'none');
// ARIA-1.2-Combobox: role=combobox gehoert auf das fokussierbare Textfeld selbst,
// nicht auf die Huelle. aria-expanded/haspopup/controls/activedescendant ebenfalls hier.
const input = document.createElement('input');
input.className = 'palette-input';
input.type = 'text';
input.setAttribute('role', 'combobox');
input.setAttribute('aria-expanded', 'true');
input.setAttribute('aria-haspopup', 'listbox');
input.setAttribute('aria-label', t('palette.aria_label'));
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-controls', 'palette-listbox');
input.placeholder = t('palette.placeholder');
input.autocomplete = 'off';
input.spellcheck = false;
const list = document.createElement('ul');
list.className = 'palette-list';
list.id = 'palette-listbox';
list.setAttribute('role', 'listbox');
list.setAttribute('aria-label', t('palette.list_label'));
const live = document.createElement('div');
live.className = 'palette-live';
live.setAttribute('aria-live', 'polite');
const hint = document.createElement('div');
hint.className = 'palette-hint';
hint.textContent = t('palette.hint');
box.append(input, list, live, hint);
overlay.appendChild(box);
// Klick auf den Overlay-Hintergrund schliesst (wie dialog.js:107)
overlay.addEventListener('click', e => {
if (e.target === overlay) close();
});
// Live-Filter
input.addEventListener('input', () => {
results = search(input.value);
renderList(list, live);
input.setAttribute('aria-activedescendant', activeIndex >= 0 ? 'palette-opt-' + activeIndex : '');
});
// Tastatursteuerung: Pfeile/Enter/Escape + Fokus-Falle (nur input fokussierbar)
keyHandler = function (e) {
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && results[activeIndex]) openResult(results[activeIndex]);
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(list, activeIndex + 1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setActive(list, activeIndex - 1); return; }
if (e.key === 'Tab') {
// Fokus-Falle ueber den Container: das Eingabefeld ist das einzige
// fokussierbare Element, also Tab/Shift+Tab immer dorthin zurueck.
e.preventDefault();
input.focus();
}
};
document.addEventListener('keydown', keyHandler);
document.body.appendChild(overlay);
// View-Transition uebernimmt das Fade; Fokus ins Eingabefeld
withViewTransition(() => {
overlay.classList.add('active');
input.focus();
});
}
// ---- globaler Ausloeser Strg+K (Meta+K auf Mac) mit Open-Guard ----
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
if (overlay) { e.preventDefault(); close(); return; }
if (isBlocked()) return;
e.preventDefault();
open();
}
});
// Persistenter Header-Trigger (BS-08): Klick toggelt die Palette wie Strg+K.
const paletteBtn = document.getElementById('btnPalette');
if (paletteBtn) {
paletteBtn.addEventListener('click', () => {
if (overlay) { close(); return; }
if (isBlocked()) return;
open();
});
}
}
+69
View File
@@ -0,0 +1,69 @@
/* =============================================
HELLION NEWTAB — quicksave-core.js
DOM-freie geteilte Helfer fuer Seite UND Background-Worker.
Laeuft als <script> (newtab.html) und via importScripts (Service-Worker/Event-Page).
KEIN window/document/Store-Zugriff. Alle Exporte auf globalThis.
============================================= */
(function (root) {
'use strict';
// Feste, nicht-zufaellige ID des Inbox-Boards (Quick-Save-Ziel).
// Bewusst KEIN uid(): die Inbox muss von Seite und Worker deterministisch
// wiedererkennbar sein, sonst entstehen Duplikate (QS-08).
const INBOX_ID = 'inbox';
// Kollisionsarme Kurz-ID. Identisch zur frueheren state.js-Variante,
// damit bestehende Aufrufer (boards.js, data.js, app.js, bookmark-import.js) unveraendert weiterlaufen.
function uid() {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
// Sichert, dass im uebergebenen boards-Array genau EIN Inbox-Board existiert,
// und gibt dieses Inbox-Board-Objekt zurueck. Idempotent: findet ein Board mit
// id === INBOX_ID, sonst legt es eins mit fester id vorne an (unshift) und mutiert
// das uebergebene Array dabei IN-PLACE. Der Worker schreibt anschliessend dasselbe
// (mutierte) boards-Array via storage zurueck; der Rueckgabewert ist das Board,
// damit Aufrufer direkt inbox.bookmarks.push(...) machen koennen (QS-08).
function ensureInbox(boardsArr) {
const list = Array.isArray(boardsArr) ? boardsArr : [];
let inbox = list.find(b => b && b.id === INBOX_ID);
if (!inbox) {
inbox = { id: INBOX_ID, title: 'Inbox', bookmarks: [], blurred: false };
list.unshift(inbox);
}
return inbox;
}
// Sicheres URL-Protokoll (http/https/ftp). Inhaltlich identisch zur data.js-Variante, aber
// DOM-frei und auf globalThis, damit der Quick-Save-Drain (app.js) dieselbe Validierung nutzt
// wie jeder andere Bookmark-Schreibpfad. URL ist in Worker UND Seite verfuegbar.
function isSafeUrl(url) {
try {
return ['http:', 'https:', 'ftp:'].includes(new URL(url).protocol);
} catch (_) {
return false;
}
}
// Normalisiert eine Bookmark in die kanonische Form { id, title, url, desc }.
// title-Fallback auf url, desc auf ''. Begrenzt Laengen wie data.js (200/500),
// damit Quick-Save-Eintraege das gleiche Schema wie Import/Manuell haben.
function normalizeBookmark(raw) {
const url = (raw && typeof raw.url === 'string') ? raw.url : '';
const title = (raw && typeof raw.title === 'string' && raw.title.trim())
? raw.title.trim()
: url;
return {
id: (raw && raw.id) ? raw.id : uid(),
title: String(title).slice(0, 200),
url: url,
desc: String((raw && raw.desc) || '').slice(0, 500)
};
}
root.INBOX_ID = INBOX_ID;
root.uid = uid;
root.isSafeUrl = isSafeUrl;
root.ensureInbox = ensureInbox;
root.normalizeBookmark = normalizeBookmark;
})(typeof globalThis !== 'undefined' ? globalThis : self);
+352 -7
View File
@@ -49,6 +49,7 @@ function openSettings() {
document.getElementById('settingsOverlay').classList.add('active'); document.getElementById('settingsOverlay').classList.add('active');
}); });
panel.setAttribute('aria-hidden', 'false'); panel.setAttribute('aria-hidden', 'false');
renderTrash();
_settingsTrap = _makeTrap(panel, closeSettings); _settingsTrap = _makeTrap(panel, closeSettings);
document.addEventListener('keydown', _settingsTrap); document.addEventListener('keydown', _settingsTrap);
const first = _focusable(panel)[0]; const first = _focusable(panel)[0];
@@ -79,6 +80,8 @@ function openThemeModal() {
document.addEventListener('keydown', _themeTrap); document.addEventListener('keydown', _themeTrap);
const first = _focusable(modal)[0]; const first = _focusable(modal)[0];
if (first) first.focus(); if (first) first.focus();
syncCustomPickers();
document.getElementById('themeBuilderPanel').classList.toggle('hidden', settings.theme !== 'custom');
} }
function closeThemeModal() { function closeThemeModal() {
const overlay = document.getElementById('themeOverlay'); const overlay = document.getElementById('themeOverlay');
@@ -112,7 +115,131 @@ function switchTheme(name) {
*/ */
function isValidBgUrl(url) { function isValidBgUrl(url) {
return typeof url === 'string' && url.length > 0 && return typeof url === 'string' && url.length > 0 &&
(url.startsWith('blob:') || url.startsWith('data:image/')); (url.startsWith('blob:') || url.startsWith('data:image/') || url.startsWith('https://'));
}
// ---- THEME-BUILDER: Konstanten + reine Helfer ----
const CUSTOM_DEFAULTS = {
accent: '#6c8cff', bgPrimary: '#0b0d12', bgBoard: '#141821',
textPrimary: '#e6e8ef', textSecondary: '#9aa3b8', textMuted: '#5b6478',
};
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function isValidHexColor(v) { return typeof v === 'string' && HEX_RE.test(v); }
function safeHex(v, fallback) { return isValidHexColor(v) ? v : fallback; }
function hexToRgba(hex, alpha) {
let h = hex.replace('#', '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// WCAG 2.x Relativluminanz + Kontrastverhaeltnis
function relLuminance(hex) {
let h = hex.replace('#', '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const lin = [0, 2, 4].map(i => {
const c = parseInt(h.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
function contrastRatio(hexA, hexB) {
const a = relLuminance(hexA), b = relLuminance(hexB);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
function updateContrastIndicator(textHex, bgHex) {
const el = document.getElementById('tbContrast');
if (!el) return;
const ratio = contrastRatio(textHex, bgHex);
let cls, key;
if (ratio >= 4.5) { cls = 'good'; key = 'theme.builder.contrast_good'; }
else if (ratio >= 3) { cls = 'ok'; key = 'theme.builder.contrast_ok'; }
else { cls = 'bad'; key = 'theme.builder.contrast_bad'; }
el.classList.remove('good', 'ok', 'bad');
el.classList.add(cls);
const txt = document.getElementById('tbContrastText');
if (txt) txt.textContent = `${t(key)} (${ratio.toFixed(1)}:1)`;
}
// Setzt data-theme='custom' + 6 validierte Inline-Vars (Gate vor jedem setProperty).
function applyCustomTheme(ct) {
const root = document.documentElement;
const c = ct || {};
const accent = safeHex(c.accent, CUSTOM_DEFAULTS.accent);
const bgPrimary = safeHex(c.bgPrimary, CUSTOM_DEFAULTS.bgPrimary);
const bgBoard = safeHex(c.bgBoard, CUSTOM_DEFAULTS.bgBoard);
const textPrimary = safeHex(c.textPrimary, CUSTOM_DEFAULTS.textPrimary);
const textSecondary = safeHex(c.textSecondary, CUSTOM_DEFAULTS.textSecondary);
const textMuted = safeHex(c.textMuted, CUSTOM_DEFAULTS.textMuted);
root.setAttribute('data-theme', 'custom');
root.style.setProperty('--accent', accent);
root.style.setProperty('--bg-primary', bgPrimary);
root.style.setProperty('--bg-board', hexToRgba(bgBoard, 0.55));
root.style.setProperty('--text-primary', textPrimary);
root.style.setProperty('--text-secondary', textSecondary);
root.style.setProperty('--text-muted', textMuted);
document.querySelectorAll('.theme-card').forEach(card => {
const on = card.dataset.value === 'custom';
card.classList.toggle('active', on);
card.setAttribute('aria-pressed', on ? 'true' : 'false');
});
updateContrastIndicator(textPrimary, bgPrimary);
// Kein eigenes Bild gesetzt -> bgLayer leeren, damit --bg-primary (Solid) durchscheint
// statt des Hintergrundbilds eines zuvor gewaehlten Presets (das sonst haengen bliebe).
if (!(settings.bgUrl && isValidBgUrl(settings.bgUrl))) {
document.getElementById('bgLayer').style.backgroundImage = '';
}
}
// Entfernt die 6 Inline-Vars (Rueckwechsel auf Preset / Reset).
function clearCustomTheme() {
const root = document.documentElement;
['--accent', '--bg-primary', '--bg-board', '--text-primary', '--text-secondary', '--text-muted']
.forEach(v => root.style.removeProperty(v));
}
// Schreibt die gespeicherten (oder Default-) Farben in die 6 Picker-Inputs.
function syncCustomPickers() {
const ct = settings.customTheme || {};
const set = (id, key) => { const el = document.getElementById(id); if (el) el.value = safeHex(ct[key], CUSTOM_DEFAULTS[key]); };
set('tbAccent', 'accent'); set('tbBg', 'bgPrimary'); set('tbBoard', 'bgBoard');
set('tbText', 'textPrimary'); set('tbTextSec', 'textSecondary'); set('tbTextMuted', 'textMuted');
}
// Eigenes Upload-Bild Quota-schonend verkleinern: auf die laengste Bildschirmkante
// (× devicePixelRatio, gedeckelt) herunterrechnen und als WebP neu kodieren. Das spart
// gegenueber dem rohen Base64-Upload locker den Grossteil der chrome.storage.local-Quota.
// Greift nur beim lokalen Upload (data:-URL ist same-origin, Canvas wird nicht getainted);
// https-Hintergruende liegen remote und kosten keine Quota.
function downscaleBgImage(dataUrl) {
const MAX_DIM = Math.min(2560, Math.round(Math.max(screen.width, screen.height) * (window.devicePixelRatio || 1)));
const QUALITY = 0.82;
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const scale = Math.min(1, MAX_DIM / Math.max(img.naturalWidth, img.naturalHeight));
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) { resolve(dataUrl); return; } // kein 2D-Context -> Original behalten
ctx.drawImage(img, 0, 0, w, h);
// WebP wo verfuegbar (Chrome/Opera/FF142+); sonst faellt toDataURL auf PNG zurueck -> dann JPEG
let out = canvas.toDataURL('image/webp', QUALITY);
if (!out.startsWith('data:image/webp')) out = canvas.toDataURL('image/jpeg', QUALITY);
resolve(out);
};
img.onerror = () => reject(new Error('image decode failed'));
img.src = dataUrl;
});
} }
// ---- ACCORDION ---- // ---- ACCORDION ----
@@ -141,6 +268,165 @@ function initAccordion() {
}); });
} }
// ---- PAPIERKORB ----
/**
* Formatiert einen deletedAt-Timestamp lokalisiert (folgt der aktiven UI-Sprache).
* @param {number} ts - Millisekunden-Timestamp
* @returns {string}
*/
function formatTrashDate(ts) {
const locale = I18n.currentLang === 'de' ? 'de-DE' : 'en-US';
return new Date(ts).toLocaleDateString(locale, { year: 'numeric', month: '2-digit', day: '2-digit' });
}
/**
* Rendert den Papierkorb in die Settings-Section. Wird bei jedem openSettings()
* sowie nach jeder Trash-Mutation aufgerufen. Baut DOM ohne innerHTML (XSS-frei,
* Titel kommen aus User-/Importdaten).
*/
function renderTrash() {
const listEl = document.getElementById('trashList');
const actionsRow = document.getElementById('trashActionsRow');
if (!listEl) return;
listEl.replaceChildren();
if (trash.length === 0) {
const empty = document.createElement('div');
empty.className = 'trash-empty';
empty.textContent = t('trash.empty');
listEl.appendChild(empty);
if (actionsRow) actionsRow.classList.add('hidden');
return;
}
if (actionsRow) actionsRow.classList.remove('hidden');
// Neueste zuerst.
const sorted = [...trash].sort((a, b) => b.deletedAt - a.deletedAt);
sorted.forEach(entry => listEl.appendChild(createTrashItemEl(entry)));
}
/**
* Baut eine einzelne Papierkorb-Zeile.
* @param {Object} entry - trash-Eintrag { item, type, originBoardId, deletedAt }
* @returns {HTMLElement}
*/
function createTrashItemEl(entry) {
const row = document.createElement('div');
row.className = 'trash-item';
const info = document.createElement('div');
info.className = 'trash-item-info';
const titleLine = document.createElement('span');
titleLine.className = 'trash-item-title';
const badge = document.createElement('span');
badge.className = 'trash-item-badge';
badge.textContent = entry.type === 'board' ? t('trash.type.board') : t('trash.type.bookmark');
const titleText = document.createTextNode(entry.item && entry.item.title ? entry.item.title : '');
titleLine.append(badge, titleText);
const meta = document.createElement('span');
meta.className = 'trash-item-meta';
let metaText = t('trash.deleted_at', { date: formatTrashDate(entry.deletedAt) });
if (entry.type === 'bookmark') {
const origin = entry.originBoardId ? boards.find(b => b.id === entry.originBoardId) : null;
metaText += origin
? ' · ' + t('trash.from_board', { board: origin.title })
: ' · ' + t('trash.from_board_unknown');
}
meta.textContent = metaText;
info.append(titleLine, meta);
const actions = document.createElement('div');
actions.className = 'trash-item-actions';
const btnRestore = document.createElement('button');
btnRestore.className = 'btn-small';
btnRestore.textContent = t('trash.restore');
btnRestore.title = t('trash.restore_title');
btnRestore.addEventListener('click', () => { btnRestore.disabled = true; restoreTrashEntry(entry); });
const btnForever = document.createElement('button');
btnForever.className = 'btn-danger';
btnForever.textContent = t('trash.delete_forever');
btnForever.title = t('trash.delete_forever_title');
btnForever.addEventListener('click', () => deleteTrashEntryForever(entry));
actions.append(btnRestore, btnForever);
row.append(info, actions);
return row;
}
/**
* Stellt einen Papierkorb-Eintrag wieder her.
* Bookmark -> in originBoardId (falls noch vorhanden), sonst in die Inbox (ensureInboxBoard).
* Board -> zurueck in boards[].
* @param {Object} entry
*/
async function restoreTrashEntry(entry) {
// Re-Entry-Guard: ein zweiter Klick (z.B. waehrend der Inbox-Alert offen ist) wuerde sonst
// das Item ein zweites Mal einfuegen (Duplikat). Nach der ersten Ausfuehrung ist entry
// nicht mehr in trash[]; btnRestore wird zusaetzlich beim ersten Klick disabled.
if (!trash.includes(entry)) return;
if (entry.type === 'board') {
// Ganzes Board zurueck (inkl. blurred). Falls die id schon existiert (Edge-Case),
// neue uid vergeben, damit nichts ueberschrieben wird.
const restored = structuredClone(entry.item);
if (boards.some(b => b.id === restored.id)) restored.id = uid();
boards.push(restored);
await saveBoards();
} else {
const restored = structuredClone(entry.item);
let target = entry.originBoardId ? boards.find(b => b.id === entry.originBoardId) : null;
let toInbox = false;
if (!target) {
// Ursprungs-Board weg -> in die Inbox (Page-Wrapper ensureInboxBoard aus Phase 1).
target = await ensureInboxBoard();
toInbox = true;
}
target.bookmarks.push(restored);
await saveBoards();
if (toInbox) {
await HellionDialog.alert(t('trash.restored_to_inbox'), { type: 'info', title: t('trash.restored_to_inbox.title') });
}
}
trash = trash.filter(e => e !== entry);
await saveTrash();
renderTrash();
renderBoards();
}
/**
* Loescht einen einzelnen Papierkorb-Eintrag endgueltig (mit Confirm).
* @param {Object} entry
*/
async function deleteTrashEntryForever(entry) {
const ok = await HellionDialog.confirm(
t('trash.delete_forever_confirm'),
{ type: 'danger', title: t('trash.delete_forever_confirm.title'), confirmText: t('trash.delete_forever') }
);
if (!ok) return;
trash = trash.filter(e => e !== entry);
await saveTrash();
renderTrash();
}
/**
* Leert den gesamten Papierkorb (mit Confirm).
*/
async function emptyTrash() {
if (trash.length === 0) return;
const ok = await HellionDialog.confirm(
t('trash.empty_confirm', { count: trash.length }),
{ type: 'danger', title: t('trash.empty_confirm.title'), confirmText: t('trash.empty_btn') }
);
if (!ok) return;
trash = [];
await saveTrash();
renderTrash();
}
// ---- APPLY SETTINGS ---- // ---- APPLY SETTINGS ----
function applySettings() { function applySettings() {
const body = document.body; const body = document.body;
@@ -184,7 +470,11 @@ function applySettings() {
const langEl = document.getElementById('settingLanguage'); const langEl = document.getElementById('settingLanguage');
if (langEl) langEl.value = settings.language || 'auto'; if (langEl) langEl.value = settings.language || 'auto';
applyTheme(settings.theme || 'nebula', !!settings.bgUrl); if (settings.theme === 'custom') {
applyCustomTheme(settings.customTheme);
} else {
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
}
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) { if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`; document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
@@ -211,10 +501,25 @@ function bindSettingsEvents() {
const themeCards = document.querySelectorAll('.theme-card'); const themeCards = document.querySelectorAll('.theme-card');
function selectThemeCard(card) { function selectThemeCard(card) {
const name = card.dataset.value; const name = card.dataset.value;
if (!name || name === settings.theme) return Promise.resolve(); if (!name) return Promise.resolve();
// Custom: VOR dem name===settings.theme-Guard, damit ein Re-Klick das Panel wieder oeffnet.
if (name === 'custom') {
settings.theme = 'custom';
if (!settings.customTheme) settings.customTheme = { ...CUSTOM_DEFAULTS };
themeCards.forEach(c => c.setAttribute('aria-pressed', c === card ? 'true' : 'false'));
applyCustomTheme(settings.customTheme); // setzt data-theme + Inline-Vars; bgUrl UNANGETASTET (Koexistenz)
syncCustomPickers();
document.getElementById('themeBuilderPanel').classList.remove('hidden');
return saveSettings();
}
if (name === settings.theme) return Promise.resolve();
settings.theme = name; settings.theme = name;
settings.bgUrl = ''; settings.bgUrl = '';
document.getElementById('bgUrlInput').value = ''; document.getElementById('bgUrlInput').value = '';
clearCustomTheme(); // Inline-Vars weg beim Rueckwechsel auf ein Preset
document.getElementById('themeBuilderPanel').classList.add('hidden');
// aria-pressed synchron halten — applyTheme/switchTheme pflegt nur die .active-Klasse, nicht ARIA // aria-pressed synchron halten — applyTheme/switchTheme pflegt nur die .active-Klasse, nicht ARIA
themeCards.forEach(c => c.setAttribute('aria-pressed', c === card ? 'true' : 'false')); themeCards.forEach(c => c.setAttribute('aria-pressed', c === card ? 'true' : 'false'));
switchTheme(name); // WICHTIG: switchTheme aus Phase 4 (View-Transition-Wrapper), NICHT applyTheme direkt — sonst geht der Theme-Fade verloren switchTheme(name); // WICHTIG: switchTheme aus Phase 4 (View-Transition-Wrapper), NICHT applyTheme direkt — sonst geht der Theme-Fade verloren
@@ -230,6 +535,31 @@ function bindSettingsEvents() {
}); });
}); });
// Theme-Builder Picker
const TB_PICKERS = [['tbAccent', 'accent'], ['tbBg', 'bgPrimary'], ['tbBoard', 'bgBoard'],
['tbText', 'textPrimary'], ['tbTextSec', 'textSecondary'], ['tbTextMuted', 'textMuted']];
TB_PICKERS.forEach(([id, key]) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', () => { // live waehrend des Ziehens
if (!settings.customTheme) settings.customTheme = { ...CUSTOM_DEFAULTS };
settings.customTheme[key] = el.value;
settings.theme = 'custom';
applyCustomTheme(settings.customTheme);
});
el.addEventListener('change', () => saveSettings()); // persistiert beim Loslassen/Schliessen
});
const tbReset = document.getElementById('tbReset');
if (tbReset) {
tbReset.addEventListener('click', async () => {
settings.customTheme = { ...CUSTOM_DEFAULTS };
applyCustomTheme(settings.customTheme);
syncCustomPickers();
await saveSettings();
});
}
// Accordion initialisieren // Accordion initialisieren
initAccordion(); initAccordion();
@@ -274,7 +604,9 @@ function bindSettingsEvents() {
// Background URL (im Theme-Modal) // Background URL (im Theme-Modal)
document.getElementById('btnChangeBg').addEventListener('click', () => { document.getElementById('btnChangeBg').addEventListener('click', () => {
document.getElementById('bgInputRow').classList.toggle('hidden'); // toggle() liefert true, wenn 'hidden' jetzt gesetzt ist -> Hinweis exakt parallel schalten
const isNowHidden = document.getElementById('bgInputRow').classList.toggle('hidden');
document.getElementById('bgUrlHint').classList.toggle('hidden', isNowHidden);
}); });
document.getElementById('btnApplyBg').addEventListener('click', async () => { document.getElementById('btnApplyBg').addEventListener('click', async () => {
const url = document.getElementById('bgUrlInput').value.trim(); const url = document.getElementById('bgUrlInput').value.trim();
@@ -298,8 +630,14 @@ function bindSettingsEvents() {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async ev => { reader.onload = async ev => {
if (!isValidBgUrl(ev.target.result)) return; if (!isValidBgUrl(ev.target.result)) return;
settings.bgUrl = ev.target.result; let bg = ev.target.result;
document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`; try {
bg = await downscaleBgImage(bg); // Quota-Schutz: verkleinern + WebP
} catch {
// Downscale fehlgeschlagen -> Original-Upload nutzen (besser als gar kein Bild)
}
settings.bgUrl = bg;
document.getElementById('bgLayer').style.backgroundImage = `url('${bg}')`;
await saveSettings(); await saveSettings();
}; };
reader.onerror = () => { reader.onerror = () => {
@@ -336,6 +674,10 @@ function bindSettingsEvents() {
Onboarding.start(); Onboarding.start();
}); });
// Papierkorb leeren
const btnEmptyTrash = document.getElementById('btnEmptyTrash');
if (btnEmptyTrash) btnEmptyTrash.addEventListener('click', emptyTrash);
// Reset All // Reset All
document.getElementById('btnResetAll').addEventListener('click', async () => { document.getElementById('btnResetAll').addEventListener('click', async () => {
const ok = await HellionDialog.confirm( const ok = await HellionDialog.confirm(
@@ -344,11 +686,14 @@ function bindSettingsEvents() {
); );
if (!ok) return; if (!ok) return;
boards = []; boards = [];
trash = [];
settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false, settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false,
hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula', hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula',
showSearch: true, searchEngine: 'google', toolbarPos: 'right', showSearch: true, searchEngine: 'google', toolbarPos: 'right',
imageRefEnabled: false, language: 'auto' }; imageRefEnabled: false, language: 'auto', customTheme: null };
clearCustomTheme();
await saveBoards(); await saveBoards();
await saveTrash();
await saveSettings(); await saveSettings();
setLanguage('auto'); setLanguage('auto');
applySettings(); applySettings();
+58 -4
View File
@@ -5,6 +5,17 @@
let boards = []; let boards = [];
// Papierkorb als EIGENER Store-Key (nicht im boards-Payload), isoliert das Quota-Risiko (CR-04/TRASH-02).
// Eintrag-Schema: { item, type: 'bookmark'|'board', deletedAt, originBoardId }
let trash = [];
// Papierkorb: Auto-Cleanup-Fenster und harte Obergrenze (Quota-Schutz, TRASH-04).
// 30 Tage in Millisekunden; ueber dieser Zeit wird ein Eintrag beim Laden auto-geloescht.
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
// Max. Anzahl trash-Eintraege. Bei Ueberlauf werden die aeltesten zuerst verworfen,
// damit der Papierkorb nicht das 10-MB-Storage-Limit sprengt (kein blindes Wachstum).
const TRASH_MAX_ENTRIES = 100;
let settings = { let settings = {
compact: false, compact: false,
shortenTitles: false, shortenTitles: false,
@@ -14,6 +25,7 @@ let settings = {
visibleCount: 10, visibleCount: 10,
bgUrl: '', bgUrl: '',
theme: 'nebula', theme: 'nebula',
customTheme: null,
showSearch: true, showSearch: true,
searchEngine: 'google', searchEngine: 'google',
toolbarPos: 'right', toolbarPos: 'right',
@@ -21,9 +33,8 @@ let settings = {
language: 'auto' language: 'auto'
}; };
function uid() { // uid() lebt jetzt in quicksave-core.js (globalThis.uid), damit Seite und
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); // Background-Worker dieselbe ID-Erzeugung teilen. Hier bewusst KEINE eigene Deklaration.
}
function escHtml(str) { function escHtml(str) {
return String(str) return String(str)
@@ -43,7 +54,9 @@ function getDefaultBoards() {
{ id: uid(), title: 'MDN Web Docs', url: 'https://developer.mozilla.org', desc: '' }, { id: uid(), title: 'MDN Web Docs', url: 'https://developer.mozilla.org', desc: '' },
{ 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 }
} }
]; ];
} }
@@ -52,6 +65,47 @@ async function saveBoards() {
await Store.set('boards', boards); await Store.set('boards', boards);
} }
async function saveTrash() {
await Store.set('trash', trash);
}
/**
* Legt einen Eintrag in den Papierkorb. Klont das Objekt (structuredClone),
* damit der trash-Eintrag nicht per Referenz an boards[] haengt und nach dem
* Restore-Loop konsistent bleibt. Setzt deletedAt. Erzwingt die harte Obergrenze
* TRASH_MAX_ENTRIES (Quota-Schutz, TRASH-04): bei Ueberlauf fallen die aeltesten
* Eintraege heraus. Speichern uebernimmt der Aufrufer (saveTrash()).
* @param {{ item: Object, type: 'bookmark'|'board', originBoardId: (string|null) }} entry
*/
function pushToTrash({ item, type, originBoardId }) {
const entry = {
item: structuredClone(item),
type,
originBoardId: originBoardId ?? null,
deletedAt: Date.now()
};
trash.push(entry);
// Aelteste zuerst kappen, falls die Obergrenze ueberschritten ist.
if (trash.length > TRASH_MAX_ENTRIES) {
trash.sort((a, b) => a.deletedAt - b.deletedAt);
trash = trash.slice(trash.length - TRASH_MAX_ENTRIES);
}
return entry; // fuer Rollback im Delete-Handler bei Save-Fehler (W-b/Quota)
}
// Page-seitiger Wrapper um das DOM-freie ensureInbox() aus quicksave-core.js.
// ensureInbox() mutiert das globale boards-Array in-place; wir persistieren nur,
// wenn die Inbox neu angelegt wurde, und geben das Inbox-Board-Objekt zurueck
// (fuer Quick-Save-/Restore-Pfade).
async function ensureInboxBoard() {
const before = boards.length;
const inbox = ensureInbox(boards); // global aus quicksave-core.js; mutiert boards in-place
if (boards.length !== before) {
await saveBoards();
}
return inbox;
}
async function saveSettings() { async function saveSettings() {
await Store.set('settings', settings); await Store.set('settings', settings);
} }