Compare commits

..

171 Commits

Author SHA1 Message Date
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 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 8c509647da merge: v2.3.0 bookmark comfort features into development 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
JonKazama-Hellion a946e66c6c ci(gitea): Release- und Quality-Workflow nach .gitea/ portieren, tote .github/-Workflows entfernen
Code Quality / Validate Extension (push) Successful in 5s
Security / scan (push) Successful in 18s
Release / Build & Release (push) Successful in 14s
2026-06-13 23:51:23 +02:00
JonKazama-Hellion 601350c5c6 Merge feature/v2.2.0: v2.2.0 Modernisierung
Security / scan (push) Successful in 20s
CSS-Glow-up und Accessibility-Pass in einem Release:
- @layer-Kaskaden-Schichtung (6 Layer)
- color-mix-Theme-Tokens (pro Theme klassifiziert, verlustfrei)
- View Transitions fuer Theme-Wechsel und zentrale Dialoge (mit Fallback)
- clamp() Fluid Typography, color-scheme: dark
- Accessibility-Pass: ARIA-Rollen, Tastatur-Navigation, Fokus-Management, prefers-reduced-motion
- Fix: stiller --border-accent-Farb-Drift in Nebula
2026-06-13 21:52:22 +02:00
JonKazama-Hellion 47393012f2 Add CHANGELOG entry for v2.2.0 (color-mix, @layer, View Transitions, clamp, color-scheme, a11y) 2026-06-13 21:18:39 +02:00
JonKazama-Hellion cbd8b5e6fb Bump export version string to 2.2.0 (app.js backup export, data.js JSON export) 2026-06-13 21:17:58 +02:00
JonKazama-Hellion 2b16b19246 Bump about-version in newtab.html to 2.2.0 (CI version-consistency gate) 2026-06-13 21:17:40 +02:00
JonKazama-Hellion d9d40c350d Bump manifest version to 2.2.0 (Chrome, Firefox, Opera) 2026-06-13 21:17:25 +02:00
JonKazama-Hellion 55e371f506 fix(a11y): Dialog-Vorrang in Fokusfalle, eindeutige Dialog-IDs, Bookmark nur Enter
- settings.js: _makeTrap bricht ab, wenn ein .dialog-overlay offen ist, damit
  der Dialog-keydown-Handler Escape/Tab allein behandelt (kein Doppelschluss,
  Fokusfalle bleibt dicht)
- dialog.js: aria-labelledby/-describedby zeigen auf instanz-eindeutige IDs
  (Date.now + Modul-Zaehler) statt feste dialogTitle/dialogBody, damit kurz
  gestapelte Dialoge dem Screenreader nicht den falschen Titel liefern
- boards.js: Bookmark-keydown reagiert bei role=link nur noch auf Enter, Space
  entfernt (Space ist Button-Semantik)
2026-06-13 21:11:49 +02:00
JonKazama-Hellion eda5fba8f3 a11y(i18n): data-i18n-aria-label-Hook plus DE/EN-Keys fuer die 11 Theme-Cards 2026-06-13 21:02:59 +02:00
JonKazama-Hellion f2e078b593 a11y(focus): getoenter :focus-visible-Ring im utilities-Layer schlaegt die outline:none-Regeln 2026-06-13 21:02:15 +02:00
JonKazama-Hellion 80af8df8b0 a11y(bookmarks): li als role=link mit tabindex und Enter-Delegation, Drag/Delegation unberuehrt 2026-06-13 21:01:47 +02:00
JonKazama-Hellion 4e527b19d5 a11y(toolbar): Widget-Toolbar als role=toolbar mit aria-label 2026-06-13 21:01:14 +02:00
JonKazama-Hellion 3e93efb785 a11y(toggles): native Checkboxen als role=switch mit synchron gepflegtem aria-checked 2026-06-13 21:00:59 +02:00
JonKazama-Hellion 02c36dba09 a11y(theme-picker): Cards als role=button mit tabindex, Enter/Space-Handler und gepflegtem aria-pressed 2026-06-13 21:00:21 +02:00
JonKazama-Hellion 085cca2812 a11y(dialog): ARIA-Rolle, Fokus-Falle und Fokus-Rueckgabe ergaenzt ohne Escape/Initial-Fokus zu doppeln 2026-06-13 20:59:39 +02:00
JonKazama-Hellion 0a93340792 a11y(modals): role=dialog + Fokus-Falle und -Rueckgabe fuer Settings und Theme-Picker 2026-06-13 20:58:42 +02:00
JonKazama-Hellion 87cd070beb refactor(view-transitions): zentralen withViewTransition-Helper in state.js, Duplikate entfernt 2026-06-13 20:42:07 +02:00
JonKazama-Hellion 6004203339 feat(boards): Inline-Modals (Add-Board/Add-Bookmark/Rename) mit View-Transition-Fade 2026-06-13 20:32:23 +02:00
JonKazama-Hellion 278eda7d69 feat(bm-import): Import-Modal mit View-Transition-Fade, Teardown in Closure 2026-06-13 20:32:07 +02:00
JonKazama-Hellion abddc59f49 feat(dialog): Custom-Dialoge mit View-Transition-Fade, Teardown in Closure 2026-06-13 20:31:33 +02:00
JonKazama-Hellion 24e9aa408b feat(settings): Settings-Panel und Theme-Picker mit View-Transition-Fade 2026-06-13 20:31:10 +02:00
JonKazama-Hellion 2bdee5f215 feat(themes): Theme-Wechsel mit View-Transition-Cross-Fade am Ausloeser 2026-06-13 20:30:56 +02:00
JonKazama-Hellion a6d14f9267 feat(css): prefers-reduced-motion-Block inkl. View-Transition-Pseudos 2026-06-13 20:30:26 +02:00
JonKazama-Hellion bb0c490cc7 feat(css): Haupt-Abstand --spacing fluid via clamp() 2026-06-13 20:20:58 +02:00
JonKazama-Hellion 2ab3965640 feat(css): Board-Titel font-size fluid via clamp() 2026-06-13 20:20:50 +02:00
JonKazama-Hellion 061669a7cc feat(css): Uhr font-size fluid via clamp() 2026-06-13 20:20:40 +02:00
JonKazama-Hellion 5a7d7feace feat(css): Logo font-size fluid via clamp() 2026-06-13 20:20:40 +02:00
JonKazama-Hellion 47eb475887 feat(css): color-scheme dark fuer native UI-Elemente 2026-06-13 20:19:52 +02:00
JonKazama-Hellion c6d2792332 refactor(css): derive --toggle-on-bg via color-mix in satisfactory, avorion, hellion-stealth 2026-06-13 19:39:36 +02:00
JonKazama-Hellion ab07c94141 refactor(css): inherit formula accent tokens from base, keep per-theme alpha as --*-pct 2026-06-13 19:38:55 +02:00
JonKazama-Hellion df8a187af2 refactor(css): derive base accent tokens via color-mix from --accent 2026-06-13 19:36:32 +02:00
JonKazama-Hellion ccbd27916c refactor(css): utilities-Layer wrappen (Board-Blur/Privacy, Helfer-Klassen) 2026-06-13 19:11:45 +02:00
JonKazama-Hellion 2b6b2c06c2 refactor(css): theme-overrides-Layer wrappen (37 theme-scoped Komponenten-Regeln nach components) 2026-06-13 19:10:45 +02:00
JonKazama-Hellion 0ed3a8fe64 refactor(css): components-Layer wrappen (Boards, Bookmarks, Panels, Modals, Widgets, Dialoge, Accordion) 2026-06-13 19:09:50 +02:00
JonKazama-Hellion 0683686fcb refactor(css): layout-Layer wrappen (Hintergrund-Ebenen, Header, Board-Grid-Wrapper) 2026-06-13 19:09:15 +02:00
JonKazama-Hellion 486438772d refactor(css): theme-Layer wrappen (11 [data-theme]-Token-Bloecke, nur Custom Properties) 2026-06-13 19:08:50 +02:00
JonKazama-Hellion 27e4c8243c refactor(css): base-Layer wrappen (Fonts, :root-Tokens, @supports-Fallback, Reset, Typografie) 2026-06-13 19:08:18 +02:00
JonKazama-Hellion 829914a271 refactor(css): theme-scoped Komponenten-Regeln aus den 11 Theme-Bloecken in Sammelblock extrahieren 2026-06-13 19:07:44 +02:00
JonKazama-Hellion af2a5c4912 feat(css): @layer-Reihenfolge deklarieren (base/theme/layout/components/theme-overrides/utilities) 2026-06-13 19:05:49 +02:00
JonKazama-Hellion 6a64df96d0 Merge pull request 'chore(deps): update github/codeql-action digest to 7211b7c' (#11) from renovate/github-codeql-action-digest into master
Security / scan (push) Successful in 16s
Reviewed-on: #11
2026-05-27 06:30:36 +00:00
renovate-bot e5f0bf3fed chore(deps): update github/codeql-action digest to 7211b7c
Security / scan (pull_request) Successful in 19s
2026-05-22 12:32:56 +00:00
JonKazama-Hellion 25e916c3be Merge pull request 'chore(deps): update actions/checkout action to v6' (#6) from renovate/actions-checkout-6.x into master
Security / scan (push) Successful in 18s
Reviewed-on: #6
2026-05-12 05:56:33 +00:00
renovate-bot 1d7680330d chore(deps): update actions/checkout action to v6 2026-05-12 05:56:33 +00:00
JonKazama-Hellion 9323fb69e8 Merge pull request 'chore(deps): update github/codeql-action action to v4' (#8) from renovate/github-codeql-action-4.x into master
Security / scan (push) Successful in 17s
Reviewed-on: #8
2026-05-12 05:56:07 +00:00
renovate-bot 07cf13efcd chore(deps): update github/codeql-action action to v4 2026-05-12 05:56:07 +00:00
JonKazama-Hellion 4c3eec7631 Merge pull request 'chore(deps): update softprops/action-gh-release action to v3' (#9) from renovate/softprops-action-gh-release-3.x into master
Security / scan (push) Successful in 21s
Reviewed-on: #9
2026-05-12 05:55:11 +00:00
renovate-bot 71225308d3 chore(deps): update softprops/action-gh-release action to v3
Security / scan (pull_request) Failing after 2s
2026-05-12 00:34:29 +00:00
JonKazama-Hellion a5958d47a4 Merge pull request 'chore(renovate): fix schema warning (prPriority)' (#10) from chore/renovate-config-schema-fix into master
Security / scan (push) Failing after 6s
Reviewed-on: #10
2026-05-12 00:14:52 +00:00
renovate-bot 99c61cf7e3 chore(renovate): fix schema (prPriority placement)
Security / scan (pull_request) Successful in 15s
Moves prPriority out of vulnerabilityAlerts (only allowed in packageRules per schema).
Fixes the recurring 'Found renovate config warnings' issue.
2026-05-11 22:17:59 +00:00
JonKazama-Hellion 32f4c92f1b Merge pull request 'chore(deps): update actions/dependency-review-action action to v5' (#7) from renovate/actions-dependency-review-action-5.x into master
Security / scan (push) Failing after 11s
Reviewed-on: #7
2026-05-10 20:37:28 +00:00
renovate-bot 4c7a33a6fa chore(deps): update actions/dependency-review-action action to v5 2026-05-10 20:37:28 +00:00
JonKazama-Hellion 9eb0bc1c3e Merge pull request 'chore(deps): pin dependencies' (#5) from renovate/pin-dependencies into master
Security / scan (push) Successful in 17s
Reviewed-on: #5
2026-05-10 20:36:46 +00:00
renovate-bot 0fb0eec7df chore(deps): pin dependencies
Security / scan (pull_request) Successful in 28s
2026-05-10 12:33:03 +00:00
JonKazama-Hellion 0d4708bf11 chore(config): migrate Renovate config (#3)
Security / scan (push) Successful in 27s
Auto-merge: Renovate config migration (matchPackagePrefixes -> matchPackageNames).
2026-05-09 15:46:45 +00:00
renovate-bot f2b070e201 chore(config): migrate config renovate.json
Security / scan (pull_request) Successful in 24s
2026-05-09 15:41:56 +00:00
JonKazama-Hellion 8176f91d4c Merge pull request 'chore: Configure Renovate' (#1) from renovate/configure into master
Security / scan (push) Successful in 17s
Reviewed-on: #1
2026-05-09 10:32:11 +00:00
JonKazama-Hellion d68bb35e7a renovate.json aktualisiert
Signed-off-by: Jon Kazama <kontakt@hellion-media.de>
2026-05-09 10:32:11 +00:00
renovate-bot 10c70f8bf9 Add renovate.json 2026-05-09 10:32:11 +00:00
JonKazama-Hellion 28b9061756 chore: add reusable security scan workflow
Security / scan (push) Failing after 11s
Calls JonKazama-Hellion/security-workflows for Semgrep SAST + Trivy
filesystem vulnerability scan. Runs on push to main/master, on every
PR, and weekly Monday 06:00 UTC.
2026-05-09 11:28:10 +02:00
JonKazama-Hellion 60a1bec00d feat(release): Version auf v2.1.0 bumpen — Calculator Upgrade
Alle 3 Manifests, newtab.html, data.js, app.js auf 2.1.0.
CHANGELOG-Eintrag mit allen 6 neuen Calculator-Modi.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:21:45 +02:00
JonKazama-Hellion 153db9c24d feat(calculator): Stationeers Calculator mit Gas/Furnace/Solar/Atmo
4 Sub-Modi: PV=nRT Gas-Rechner, Furnace Combustion,
Solar/Battery Planung und Atmosphere Mixer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:20:09 +02:00
JonKazama-Hellion 2e691b8b51 fix(calculator): save() überschreibt Game-Mode Sub-States nicht mehr
data.calculator wurde bei jedem save() komplett ersetzt, wodurch
factorio/satisfactory Sub-Mode-Präferenzen verloren gingen.
Jetzt werden nur die Core-Properties einzeln gesetzt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:16:19 +02:00
JonKazama-Hellion 11419bd589 feat(calculator): Factorio Calculator mit Ratio, Belt und Maschinen-Modus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:12:28 +02:00
JonKazama-Hellion 27fa4f53af feat(calculator): Satisfactory Calculator mit Overclock-Power 2026-04-16 22:10:11 +02:00
JonKazama-Hellion 8fdd46beec feat(calculator): Unit-Converter mit 6 Kategorien 2026-04-16 22:05:51 +02:00
JonKazama-Hellion 3dd9723271 fix(calc-scientific): sqrt/square mit lastResult, Negate-Regex
sqrt und x² transferieren _lastResult in _currentExpr wenn Expression leer.
handleNegate Regex akzeptiert auch dezimal-first Zahlen (.5).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:03:02 +02:00
JonKazama-Hellion 2f23c13de1 feat(calculator): Scientific-Modus implementieren
Neuer Taschenrechner-Modus mit 6 wissenschaftlichen Buttons (√, x², xⁿ, π, e, ±),
Formel-Helfer (Kreisfläche, Kreisumfang, °C↔°F, Pythagoras, Prozent) und
erweiterter Tastaturunterstützung (p → Pi, ^ → Potenz).

- calc-scientific.js: IIFE, registriert 'scientific' Mode via Calculator.registerMode()
- newtab.html: Script-Tag nach calculator.js eingefügt (Load-Order eingehalten)
- i18n.js: 15 neue Keys pro Sprache (DE + EN)
- main.css: Styles für .calc-sci-buttons und .calc-formula-* Komponenten
2026-04-16 21:59:10 +02:00
JonKazama-Hellion f5cebd8d34 fix(calculator): Dezimal-Eingabe nach ^ ermöglichen
Split-Regex für Dezimalpunkt-Check um ^ erweitern,
damit z.B. 2.3^1.5 korrekt eingegeben werden kann.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:56:29 +02:00
JonKazama-Hellion 10318008e6 feat(calculator): Parser um ^ (Potenz) und sqrt() erweitern 2026-04-16 21:51:30 +02:00
JonKazama-Hellion 50319f8ba9 fix(calculator): init-Reihenfolge und save() Daten-Erhalt
Standard-Modus vor open() registrieren (verhindert leeres Widget bei Restore).
save() nutzt read-modify-write statt Overwrite (bewahrt Timer/ImageRef-Daten).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:49:51 +02:00
JonKazama-Hellion b71e8cde1b fix(calculator): Auto-Resize via DOM statt nicht-existierendem WidgetManager.resize()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:45:35 +02:00
JonKazama-Hellion 2487ac772f feat(calculator): Tab-System mit registerMode() und switchMode()
- Neue Eigenschaften: _modes (Map), _activeMode, _tabBarEl
- registerMode() für externe Mode-Dateien, aktualisiert Tab-Bar live
- renderBody() baut jetzt Tab-Bar + .calc-mode-body Container
- _renderTabBar(), _updateTabBar(), switchMode() implementiert
- _renderStandardMode() extrahiert aus altem renderBody()
- save()/load(): activeMode wird persistiert und wiederhergestellt
- onClose(): aktiven Modus sauber aufräumen, _tabBarEl zurücksetzen
- CSS: .calc-tab-bar, .calc-tab, .calc-tab-icon, .calc-tab-label, .calc-mode-body
- i18n: calculator.tab.standard (DE + EN)
2026-04-16 21:43:03 +02:00
JonKazama-Hellion 7be391de99 docs(plan): Calculator Upgrade v2.1.0 Implementierungsplan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:38:57 +02:00
JonKazama-Hellion cebf277a5d docs(spec): Calculator Upgrade v2.1.0 Design-Spec
6 Modi: Standard, Scientific, Unit-Converter, Satisfactory, Factorio, Stationeers.
Tab-basierte Architektur, Registrierungs-Pattern, erweiteter Shunting-Yard Parser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:22:07 +02:00
JonKazama-Hellion 92c5b23b44 fix: image-ref Titel-Bug, CSP, ARIA-Labels, Onboarding i18n
- image-ref.js: .widget-title-text → .widget-title (Titel-Update war kaputt)
- Alle 3 Manifests: explizites content_security_policy (script-src/object-src 'self')
- i18n.js: applyLanguage() setzt aria-label synchron zu title
- newtab.html: 6 Close-Buttons + Search-Submit mit data-i18n-title
- i18n.js: 4 neue Keys (dialog.close, search.submit_title, onboarding.tradecenter_desc)
- onboarding.js: TradeCenter-Beschreibung auf t() umgestellt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:51:54 +02:00
JonKazama-Hellion 7f22627272 Merge branch 'feature/hardening-v2.0.1' — Hardening Release v2.0.1
Security: URL-Validierung (bgUrl + Import), immutable Data-Mapping
Stability: Widget Event-System (EventTarget), transitionend Race Fix
Privacy: Lokale Letter-Icons statt Google Favicons API
Compat: backdrop-filter Fallback, _locales in Release-ZIPs
i18n: 10 fehlende Übersetzungs-Keys ergänzt
Quality: Clock Interval ID, Notes-Import über init()
2026-04-16 20:37:03 +02:00
JonKazama-Hellion 9b6515aab3 fix(ci): _locales/ in alle Release-ZIPs aufnehmen
Manifest nutzt __MSG_extName__ / __MSG_extDesc__ mit default_locale,
aber die _locales/ Dateien fehlten in allen drei ZIP-Befehlen.
Ohne sie kann der Browser die Extension nicht laden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:35:59 +02:00
JonKazama-Hellion 675e21d886 fix(release): fehlende Versionsnummern + bgUrl-Validierung
Drei Stellen hatten noch '2.0.0' statt '2.0.1': newtab.html About-Sektion,
data.js Export und app.js Backup-Export. FileReader-Upload in settings.js
validiert jetzt bgUrl via isValidBgUrl() (Defense-in-Depth).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:33:38 +02:00
JonKazama-Hellion 536e0771a4 chore(release): bump version to v2.0.1 — hardening release
Security fixes, widget event system, local favicons, i18n completeness,
backdrop-filter fallback, code quality improvements. See CHANGELOG.md.
2026-04-16 20:28:22 +02:00
JonKazama-Hellion 02cdee76a8 fix(i18n): complete missing translations for toolbar tooltips and button texts
Add data-i18n-title to 5 header buttons, data-i18n to 3 settings buttons.
Add 10 new keys to STRINGS.de and STRINGS.en including background URL
validation error messages.
2026-04-16 20:27:17 +02:00
JonKazama-Hellion b6d347cd15 fix(compat): add backdrop-filter fallback for Brave Shields
Add --bg-solid-fallback CSS variable to all 11 themes and a
@supports not (backdrop-filter) block. UI remains usable when
Brave Shields or strict fingerprinting settings block backdrop-filter.
2026-04-16 20:25:10 +02:00
JonKazama-Hellion 6704f4c955 feat(privacy): replace Google Favicons with local letter icons
Remove getFaviconUrl() and all external network requests. Bookmarks now
show a colored letter icon with deterministic hue based on title.
Eliminates privacy leak and Brave Shields compatibility issues.
2026-04-16 20:22:18 +02:00
JonKazama-Hellion a3e21a760f fix(security): harden JSON import with URL validation and immutable mapping
Add isSafeUrl() to block javascript:/data: URLs in imported bookmarks.
Replace mutable object mutation with immutable .map() and string length limits.
Use Notes.init()/Calculator.load()/Timer.load() instead of direct internal
mutation after import.
2026-04-16 20:20:38 +02:00
JonKazama-Hellion 82dd6e026a fix(security): validate background URL before CSS injection
Add isValidBgUrl() that only allows blob: and data:image/ protocols.
Applied in applySettings() and the manual URL input handler.
Prevents CSS injection via manipulated bgUrl storage values.
2026-04-16 20:18:42 +02:00
JonKazama-Hellion 2430d65e3a refactor(widgets): migrate Calculator, Timer, ImageRef to event listeners
Replace monkey-patching of WidgetManager.close/minimize/openWidget with
WidgetManager.on() event listeners. Eliminates 3-deep closure chain.
2026-04-16 20:15:08 +02:00
JonKazama-Hellion 30df93a4cc fix(widgets): harden minimize transitionend with fallback and property filter
- Filter transitionend by propertyName=opacity to prevent double-fire
- Add 350ms fallback setTimeout for prefers-reduced-motion / zero-duration
- Initialize _minimizing: false in create() for clean state
- Dispatch events after save() for consistent state in listeners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:13:47 +02:00
JonKazama-Hellion b92ea5a1a4 fix(widgets): replace setTimeout with transitionend in minimize
Fixes race condition where openWidget() during the 250ms timeout would
be overridden. Uses _minimizing flag to cancel in-flight transitions.
Dispatches widget:minimize and widget:open events.
2026-04-16 20:10:30 +02:00
JonKazama-Hellion fde1fdd002 fix(widgets): dispatch close event before registry cleanup
Move widget:close dispatch before _widgets.delete() so handlers can
still query WidgetManager for the widget's state during the event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:09:43 +02:00
JonKazama-Hellion 7cda3019c8 refactor(widgets): add EventTarget-based lifecycle event system
Add _emitter, on(), off() to WidgetManager. Dispatch widget:close event
after close(). Foundation for removing monkey-patching from widget modules.
2026-04-16 20:07:04 +02:00
JonKazama-Hellion 3de1dd3b8b docs(plan): Hardening v2.0.1 Implementierungsplan erstellen
9 Tasks, 16 Dateien, Foundation-First-Strategie.
Event-System → Widget-Migration → Security → Favicons → i18n → Quality → Release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:29:14 +02:00
JonKazama-Hellion 63825cd393 docs(spec): Hardening v2.0.1 Design-Dokument erstellen
Audit-basierte Spec für Security, Stability, i18n und Code-Quality Fixes.
Foundation-First-Strategie: Event-System → Widget-Migration → Security → i18n → Quality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:25:03 +02:00
JonKazama-Hellion c6c0d5c468 docs(release): architecture.md auf v2.0.0 aktualisieren
i18n.js und bookmark-import.js ergänzen, Load-Order und
Module-Tabelle an aktuelle newtab.html synchronisieren.
2026-03-22 18:38:18 +01:00
JonKazama-Hellion dbd209bc2b docs(release): README auf v2.0.0 mit i18n-Feature aktualisieren
Version, i18n-Sektion, Architecture-Tree mit i18n.js und _locales,
Modulanzahl und Release-Beispiel aktualisiert.
2026-03-22 18:33:27 +01:00
JonKazama-Hellion 7900962c5a fix(i18n): Review-Findings beheben
- Version in newtab.html auf 2.0.0 aktualisieren
- dialog.js OK-Fallback auf t('dialog.ok') umstellen
- Duplikat-Keys widgets.minimize/close entfernen (widget.* wird genutzt)
2026-03-22 18:28:35 +01:00
JonKazama-Hellion 1bbdbdef1c feat(release): Version auf v2.0.0 bumpen — i18n Release
Alle drei Manifests, Export-Versionen und CHANGELOG aktualisiert.
2026-03-22 18:28:35 +01:00
JonKazama-Hellion f07200cd8e feat(i18n): settings.js und widgets.js auf t() umstellen
Reset-Dialog, Dateifehler, Widget-Buttons (Minimieren/Schließen)
und Default-Titel verwenden jetzt i18n-Keys.
2026-03-22 18:28:35 +01:00
JonKazama-Hellion ab165d4f75 feat(i18n): app.js Strings auf t() umstellen
Clock-Tage/Monate, Backup-Reminder, HTML-Import und
URL-Validierung verwenden jetzt i18n-Keys.
2026-03-22 18:28:35 +01:00
JonKazama-Hellion 4a66015258 feat(i18n): data.js, bookmark-import.js, storage.js auf t() umstellen 2026-03-22 18:28:35 +01:00
JonKazama-Hellion d0f870ace1 feat(i18n): calculator.js, timer.js, image-ref.js auf t() umstellen 2026-03-22 18:28:35 +01:00
JonKazama-Hellion daea57a9df feat(i18n): notes.js Strings auf t() umstellen 2026-03-22 18:28:35 +01:00
JonKazama-Hellion f937f7c39c feat(i18n): onboarding.js Strings auf t() umstellen 2026-03-22 18:28:31 +01:00
JonKazama-Hellion 3ab8847f31 feat(i18n): boards.js Strings auf t() umstellen 2026-03-22 18:28:25 +01:00
JonKazama-Hellion 36335d3cc4 feat(i18n): dialog.js Defaults auf t() umstellen 2026-03-22 18:28:20 +01:00
JonKazama-Hellion 1b39ac863b fix(i18n): Code-Quality-Review Korrekturen
- resolveLang() Hilfsfunktion extrahieren (DRY)
- JSDoc-Kommentar in I18n.init() korrigieren
- settings.export.btn Key für Export-Button trennen
- setLanguage('auto') im Reset-Handler aufrufen
2026-03-22 14:07:17 +01:00
JonKazama-Hellion 522b177470 fix(i18n): Spec-Review-Korrekturen für i18n-Grundgerüst
- header.settings DE-String korrigiert (war englisch)
- ~17 deutsche Strings die englisch waren gefixt
- I18n.init() vor applySettings() in app.js
- html lang-Attribut dynamisch via I18n setzen
- Default html lang auf en (passend zu default_locale)
2026-03-22 14:04:04 +01:00
JonKazama-Hellion f2d4e22b86 feat(i18n): i18n-Modul, _locales und data-i18n Attribute einrichten 2026-03-22 13:56:04 +01:00
JonKazama-Hellion 677344f24d docs(release): Dokumentation ins Englische übersetzen und v1.11.1 Docs
- README, CHANGELOG, DISCLAIMER, SECURITY auf Englisch übersetzen
- Projekt-Docs (architecture, patterns, widget-schema, style-guide) übersetzen
- CODEOWNERS für Master-Branch-Schutz hinzufügen
- release.yml auf Englisch übersetzen
- STYLE_GUIDE von src/css/ nach docs/ verschieben
2026-03-22 13:12:24 +01:00
JonKazama-Hellion 40d4d9f37a feat(app): 3 neue Themes, WebP-Konvertierung und Browser-Bookmark-Import
- Satisfactory, Avorion und Hellion Stealth als neue Themes
- Alle 11 Theme-Bilder von JPG/PNG nach WebP konvertieren (~12 MB → 1.1 MB)
- Browser-Lesezeichen direkt importieren mit Ordner-Auswahl Modal
- Duplikat-Erkennung, URL-Validierung, Chrome/Firefox-Kompatibilität
- Version auf 1.11.1 aktualisieren (Manifeste, data.js, newtab.html, app.js)
2026-03-22 13:12:24 +01:00
JonKazama-Hellion 198171b6c2 feat(app): Onboarding, Settings-Redesign und Docs für v1.9.0
- Onboarding mit Widget-Toolbar Slide und Gaming Starter Board
- Settings in Darstellung-Modal und schlankes Settings-Panel
- About-Block als fixierten Footer im Settings-Panel
- Dropdown-Optionen an Theme-Farben anpassen
- Projekt-Dokumentation (Architektur, Widget-Schema, Patterns)
- Firefox Update-URL für Store-Veröffentlichung
- Versions-Bump auf 1.9.0 in allen Manifests
2026-03-22 13:12:24 +01:00
JonKazama-Hellion 51947b229c feat(image-ref): Bild-Referenz Widget mit Session-Storage
Opt-in Widget fuer Bild-Referenzen (max. 3 gleichzeitig).
Canvas API konvertiert zu WebP, sessionStorage fuer Bilddaten.
Positionen und Labels bleiben persistent, Bilder nur pro Session.
2026-03-22 13:12:24 +01:00
JonKazama-Hellion 2f0b76eb4e feat(timer): Timer/Countdown-Widget mit Presets und Alarm
Countdown-Timer als Single-Instance-Widget mit Preset-System
(max. 5), Web Audio API Alarm und Tab-Titel-Blink bei Ablauf.
Mute-Toggle zum Stummschalten des Alarms.
Z-Index-Hierarchie für Widgets auf 100 angehoben.
2026-03-22 13:12:24 +01:00
JonKazama-Hellion 32a6fe88dc feat(calculator): Taschenrechner-Widget mit History und Tastatureingabe
Neues Widget-Modul mit Shunting-Yard Parser, 4x5 Button-Grid,
persistenter History (max 10) und Keyboard-Support.
Storage-Handling in Notes/Data erweitert fuer parallele Persistierung.
2026-03-22 13:12:24 +01:00
JonKazama-Hellion 95e45948be Add CODEOWNER file to github 2026-03-22 12:45:04 +01:00
JonKazama-Hellion a76f63c407 Add Ko-fi funding information
Added Ko-fi username for funding support.
2026-03-21 19:56:41 +01:00
JonKazama-Hellion 18a04b884c refactor(app): Sticky-Note durch Widget-System ersetzen
Migration alter Sticky-Daten in das neue Widget-System, Notes.init()
statt initStickyNote(), Toolbar-Position in Settings, JSON-Export/Import
um Notes erweitert, Onboarding-Text aktualisiert.
2026-03-21 19:40:43 +01:00
JonKazama-Hellion 7a16462358 feat(widgets): Widget-System mit Notes, Checklisten und Notebook-Sidebar
Neues modulares Widget-System als Ersatz für die alte Sticky Note.
Widget-Manager (Drag, Resize, Z-Index, Persistierung), Freitext-Notes
mit Zeichenzähler, Checklisten mit Toggle/Add/Remove, Notebook-Sidebar
mit 5 Slots, Widget-Toolbar am rechten Rand.
2026-03-21 19:40:26 +01:00
JonKazama-Hellion ed11827321 chore(meta): Ko-fi-Link einbauen und v2-Planung aus Git ausschliessen
- README: Ko-fi Badge und Link in Impressum-Tabelle
- newtab.html: Ko-fi-Link in About-Section mit Kaffeetassen-Icon
- .gitignore: v2-planning.md ausschliessen
2026-03-21 19:17:07 +01:00
73 changed files with 16312 additions and 1135 deletions
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Projektstruktur prüfen
run: |
@@ -45,6 +45,11 @@ jobs:
assert m.get('name'), 'Chrome: Name fehlt'
assert m.get('version'), 'Chrome: Version 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'])
with open('manifest.firefox.json') as f:
@@ -52,6 +57,11 @@ jobs:
assert mf.get('manifest_version') == 3, 'Firefox: Manifest V3 erwartet'
assert mf['version'] == m['version'], 'Firefox: Version stimmt nicht mit Chrome überein!'
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'])
with open('manifest.opera.json') as f:
+123
View File
@@ -0,0 +1,123 @@
name: Release
# Wird bei einem vX.Y.Z-Tag-Push ausgeloest. Baut die drei Web-Extension-ZIPs
# (Chrome/Firefox/Opera) und haengt sie ans passende Gitea-Release.
#
# Portiert von GitHub Actions auf Gitea Actions (2026-06): der fruehere
# softprops/action-gh-release-Step ist GitHub-spezifisch und laeuft auf Gitea
# nicht. Ersetzt durch die Gitea-native release-action (volle gitea.com-URL,
# da DEFAULT_ACTIONS_URL=github nackte Namen sonst von github.com zieht).
# Muster uebernommen aus HellionChat/.gitea/workflows/release.yml.
on:
push:
tags:
- 'v*'
# Manueller Recovery-Trigger: in Gitea "Run workflow" und den Tag (z.B. v2.2.0)
# im Ref-Dropdown waehlen, NICHT master. Der Validate-Step unten failt hart
# bei einem Nicht-Tag-Ref, weil die release-action GITHUB_REF direkt liest.
workflow_dispatch:
permissions:
contents: write
jobs:
build-release:
name: Build & Release
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# release-action liest GITHUB_REF direkt (kein tag_name-Input). Vorab
# validieren, damit manuelle Dispatches von einem Branch-Ref hier laut
# scheitern statt nach einem vollen Build.
- name: Validate tag ref
run: |
if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then
echo "::error::Release-Workflow muss auf einem v*-Tag laufen, got ${GITHUB_REF}"
echo "::error::Tag pushen, oder im workflow_dispatch-Ref-Dropdown den Tag (nicht master) waehlen."
exit 1
fi
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Extract version from tag
id: version
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Create Chrome/Edge ZIP (Manifest V3)
run: |
mkdir -p dist
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" ".gitea/*" "src/js/opera/*"
- name: Create Firefox ZIP (Manifest V3)
run: |
cp manifest.json manifest.chrome-backup.json
cp manifest.firefox.json manifest.json
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" ".gitea/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*"
mv manifest.chrome-backup.json manifest.json
- name: Create Opera/Opera GX ZIP (Manifest V3 + workaround)
run: |
cp manifest.json manifest.chrome-backup.json
cp manifest.opera.json manifest.json
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \
manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" ".gitea/*" "manifest.chrome-backup.json" "manifest.opera.json"
mv manifest.chrome-backup.json manifest.json
- name: Generate SHA256 checksums
run: |
cd dist
sha256sum *.zip > checksums-sha256.txt
cat checksums-sha256.txt
# Release per Gitea-API (curl), NICHT via gitea.com/actions/release-action: die ist `using: go`
# und stirbt auf diesem Runner mit exit 127 ("go not found"), weil act_runner v0.6.1 die go-Action
# weder im Job-Image noch im Runner kompiliert bekommt. curl + python3 sind im Job-Image vorhanden
# und laufen als normaler Step -> unabhaengig von go-Toolchain, Action-Cache und @main-Drift.
# GITHUB_API_URL/GITHUB_REPOSITORY/GITHUB_TOKEN injiziert Gitea Actions automatisch.
- name: Create release & upload assets (Gitea API)
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
API="${GITHUB_API_URL:-https://gitea.hellion-forge.cloud/api/v1}"
REPO="${GITHUB_REPOSITORY}"
AUTH="Authorization: token ${GITEA_TOKEN}"
# Release-Request-JSON (Body inkl. Installationshinweise) als python-Einzeiler bauen
# (mehrzeilig wuerde den YAML-run-Block brechen: Zeilen auf Spalte 0).
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}))')
# 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
# Vorhandene gleichnamige Assets entfernen (idempotent bei Re-Runs), dann hochladen.
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."
+13
View File
@@ -0,0 +1,13 @@
name: Security
on:
push:
branches: [main, master]
pull_request:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
jobs:
scan:
uses: JonKazama-Hellion/security-workflows/.gitea/workflows/security-scan.yml@main
+4
View File
@@ -0,0 +1,4 @@
# Hellion NewTab — Code Owners
# Alle Änderungen müssen von @JonKazama-Hellion approved werden
* @JonKazama-Hellion
+7
View File
@@ -0,0 +1,7 @@
# Hellion NewTab — Support & Funding
# All tools are free and open-source. Donations are voluntary and go toward server costs.
ko_fi: hellionmedia
custom:
- "https://hellion-media.de"
-76
View File
@@ -1,76 +0,0 @@
# Release — erstellt ZIP-Pakete für Chrome, Firefox und Opera bei neuem Tag
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build-release:
name: Build & Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Version aus Tag extrahieren
id: version
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Chrome/Edge ZIP erstellen (Manifest V3)
run: |
mkdir -p dist
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \
manifest.json newtab.html src/js/*.js src/css/ assets/ \
-x "*.git*" "dist/*" ".github/*" "src/js/opera/*"
- name: Firefox ZIP erstellen (Manifest V3)
run: |
cp manifest.json manifest.chrome-backup.json
cp manifest.firefox.json manifest.json
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \
manifest.json newtab.html src/js/*.js src/css/ assets/ \
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*"
mv manifest.chrome-backup.json manifest.json
- name: Opera/Opera GX ZIP erstellen (Manifest V3 + Workaround)
run: |
cp manifest.json manifest.chrome-backup.json
cp manifest.opera.json manifest.json
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \
manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ \
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.opera.json"
mv manifest.chrome-backup.json manifest.json
- name: SHA256 Checksummen erstellen
run: |
cd dist
sha256sum *.zip > checksums-sha256.txt
cat checksums-sha256.txt
- name: GitHub Release erstellen
uses: softprops/action-gh-release@v2
with:
name: "Hellion NewTab ${{ steps.version.outputs.tag }}"
body: |
## Hellion NewTab ${{ steps.version.outputs.tag }}
### Installation
- **Chrome / Edge / Brave / Vivaldi:** `hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip`
- **Firefox:** `hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip`
- **Opera / Opera GX:** `hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip`
Siehe [README](README.md) für die vollständige Installationsanleitung.
### Checksummen
Siehe `checksums-sha256.txt` zur Integritätsprüfung.
files: |
dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip
dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip
dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip
dist/checksums-sha256.txt
generate_release_notes: true
-42
View File
@@ -1,42 +0,0 @@
# Sicherheitsprüfung — läuft bei Push und PR auf main/master
name: Security Scan
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
schedule:
# Wöchentlich Montag 06:00 UTC
- cron: '0 6 * * 1'
permissions:
contents: read
security-events: write
jobs:
codeql:
name: CodeQL Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript
- name: Run CodeQL Analysis
uses: github/codeql-action/analyze@v3
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Dependency Review
uses: actions/dependency-review-action@v4
+8
View File
@@ -15,7 +15,15 @@ dist/
*.tar.gz
node_modules/
/xpi/
v2-planning.md
themes-v2.md
# Firefox Update-Manifest (wird auf hellion-media.de gehostet)
updates.json
# Persönliche Backup-Dateien (nicht ins Repo)
favorites_*.html
*_backup*.json
.mcp.json
.claude
.superpowers/
+227 -54
View File
@@ -1,91 +1,264 @@
# ⬡ Hellion Dashboard — Changelog
Alle relevanten Änderungen pro Version. Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
All notable changes per version. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
> Changelog entries can be written in English or German. English preferred for consistency.
---
## [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
### Added
- **View Transitions** — Native cross-fade on theme switch and central modals (Settings, Theme-Picker, custom dialogs, bookmark import, add-board, add-bookmark, rename). Feature-detected via `document.startViewTransition`, instant swap on older browsers. Widgets, notebook sidebar and onboarding deliberately excluded.
- **`color-scheme: dark`** — Declares the dark UA scheme so native scrollbars and form controls match the dark themes.
- **Accessibility pass** — `role="dialog"` / `aria-modal` / `aria-labelledby` on Settings and Theme-Picker with new focus trap, Escape handling and focus return; `role="toolbar"` + per-button `aria-label` on the widget toolbar; keyboard-operable theme cards (`role="button"`, `tabindex`, Enter/Space); `role="switch"` + `aria-checked` on settings toggles; focusable boards and bookmarks; visible `:focus-visible` ring tinted in the theme accent. New ARIA strings run through the i18n pipeline. Verified with Lighthouse and the axe DevTools extension, not a formal WCAG 2.2 AA audit.
### Changed
- **`color-mix()` token refactor** — Accent-derived color tokens now computed via `color-mix()` from `var(--accent)`, classified per theme (formula vs. override). Theme-specific alpha values and real special colors stay overrides; no visible theme change. `--border-accent` `:root` drift (179,92,255 → 179,89,255) fixed at both the Nebula block and the `:root` default.
- **`@layer` cascade ordering** — CSS reorganized into six layers (base / theme / layout / components / theme-overrides / utilities) so theme component overrides win deterministically instead of relying on selector specificity.
- **`clamp()` fluid typography** — Clock, logo, board titles and main spacing scale fluidly via `clamp()`. Existing 768px / 480px breakpoints kept as a safety net.
### Accessibility
- **`prefers-reduced-motion`** — Unlayered `@media` block disables transitions and animations, including the `::view-transition-*` pseudo-elements. The 350ms widget teardown fallback timer is retained so widgets still close when `transitionend` no longer fires under reduced motion.
---
## [2.1.0] — 2026-04-16
### Added
- **Calculator Tab-System:** 6 Modi über Tab-Leiste erreichbar (Standard, Scientific, Unit, SAT, FAC, STA)
- **Scientific-Modus:** Wurzel, Potenz, Pi, Euler, Vorzeichen-Wechsel + Formel-Helfer (Kreis, Pythagoras, Prozent, Temperatur)
- **Unit-Converter:** 6 Kategorien (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche) mit Live-Konvertierung und Swap
- **Satisfactory Calculator:** Items/Min, Overclock-Power (Exponent 1.321928), Maschinen-Rechner
- **Factorio Calculator:** Assembler-Ratios, Belt-Throughput, Maschinen-Rechner mit Belt-Empfehlung
- **Stationeers Calculator:** Idealgas (PV=nRT), Furnace/Verbrennung, Solar/Batterie-Dimensionierung, Atmosphären-Mixer
### Changed
- Parser um `^` (Potenz, rechts-assoziativ) und `sqrt()` erweitert
- Calculator-Widget Auto-Resize auf 320×480 für komplexe Modi
- ~110 neue i18n-Keys (DE + EN)
---
### v2.0.1 — 16.04.2026
#### Security
- **Background URL validation** — Only `blob:` and `data:image/` protocols allowed in CSS `backgroundImage` (prevents CSS injection via manipulated storage)
- **Import URL validation** — `javascript:`, `data:`, and other unsafe protocols are blocked during JSON import
- **Immutable import mapping** — Imported boards, bookmarks, and notes are sanitized with explicit field selection and string length limits
#### Fixed
- **Widget minimize race condition** — Replaced `setTimeout` with `transitionend` event; `openWidget()` during animation no longer causes display glitch
- **Notes import mutation** — Import now uses `Notes.init()` instead of directly setting `Notes._notes`
- **Complete i18n coverage** — 5 header button tooltips and 3 settings button texts now have `data-i18n` attributes (10 new translation keys)
#### Changed
- **Widget event system** — `WidgetManager` now dispatches `widget:close`, `widget:minimize`, `widget:open` CustomEvents via `EventTarget`. Calculator, Timer, and ImageRef use `WidgetManager.on()` instead of monkey-patching
- **Local favicon icons** — Replaced Google Favicons API with local colored letter icons (deterministic hue per title). Zero external network requests, Brave Shields compatible
- **backdrop-filter fallback** — `@supports not (backdrop-filter)` block with `--bg-solid-fallback` per theme for Brave Shields compatibility
- **Clock interval cleanup** — `setInterval` ID stored in variable
---
### v2.0.0 — 22.03.2026
#### New Features
- **Internationalization (i18n)** — Full DE/EN language support with runtime switching
- Language setting in Settings panel: German, English or Auto-detect (browser language)
- `i18n.js` module with ~220+ string keys, `t(key, vars?)` helper and `data-i18n` HTML attributes
- `_locales/de/` and `_locales/en/` for manifest-level i18n (`__MSG_extName__`, `__MSG_extDesc__`)
- `<html lang>` attribute updates dynamically when language changes
- All modules migrated: dialog, boards, onboarding, notes, calculator, timer, image-ref, data, bookmark-import, storage, settings, widgets, app
#### Technical
- New script load order: `storage → state → i18n → dialog → ...`
- `applyLanguage()` scans DOM for `data-i18n`, `data-i18n-placeholder`, `data-i18n-title`
- Onboarding slides use i18n keys instead of hardcoded text (rendered at display time)
- Clock day/month names via i18n keys instead of hardcoded arrays
- `resolveLang()` helper for DRY language resolution (auto → browser detect)
---
### v1.10.0 — 22.03.2026
#### Themes
- **3 new themes** — Satisfactory (Industrial Desert), Avorion (Deep Void) and Hellion Stealth (Tactical Recon)
- Now **11 themes** total, each with its own accent colors, overlays and font styles
- Satisfactory has increased board alpha (0.65) and stronger blur (12px), a deliberate choice for better readability on a visually busy background
- Avorion uses a radial gradient overlay so the ship in the center of the image stays visible
- Hellion Stealth is the only theme with a `border-left` hover effect in tactical scanner style
---
### v1.9.0 — 22.03.2026
#### New Features
- **Onboarding reworked** — 7 slides instead of 6, new slide explains the widget toolbar with all widgets
- **Gaming Starter Board** — Opt-in during onboarding: pre-filled board with links to Satisfactory, Factorio, Avorion, Minecraft and Star Citizen
- **Settings redesign** — Settings panel slimmed down to 3 sections (Widgets, Data & Help, Danger Zone)
- **Appearance modal** — Theme picker and all display settings combined in one modal instead of spread across the panel
- **Fixed about footer** — Developer info, license and links are now permanently visible at the bottom of the settings panel
- **Project documentation** — `docs/architecture.md`, `docs/widget-schema.md` and `docs/patterns.md` for anyone who wants to fork or contribute
#### Improvements
- All labels and descriptions unified in German, no more language mix
- Dropdown options use theme colors instead of white browser default
- Firefox update URL for store publishing added to `manifest.firefox.json`
---
### v1.8.0 — 21.03.2026
#### New Features
- **Image Reference Widget** — Drop images as floating reference widgets (max. 3 at once)
- Canvas API WebP conversion for smaller file sizes, all local in the browser
- Two-layer storage: metadata persistent, image data session-only (sessionStorage)
- Load images via drag & drop or file dialog
- Labels editable with debounced save
- Feature is off by default, enable via Settings → Widgets
---
### v1.7.1 — 21.03.2026
#### Improvements
- **Timer mute toggle** — Alarm can be muted via icon button without restarting the timer
- Alarm volume reduced to 7%, 30% was a bit much
- Mute state is saved and persists on next open
---
### v1.7.0 — 21.03.2026
#### New Features
- **Calculator widget** — Shunting-yard parser (no `eval()`), history of last calculations, keyboard input
- **Timer/countdown widget** — Saveable presets, Web Audio API alarm, tab title blinks when timer completes
- **Widget z-index fix** — Widgets now correctly render above the search bar (z-index 100+)
---
### v1.6.0 — 21.03.2026
#### New Features
- **Widget system** — Draggable, resizable floating panels managed by WidgetManager
- **Notes & checklists** — Multi-instance widgets (max. 5) with text and checklist template, Markdown support, export as `.md`
- **Notebook sidebar** — All notes at a glance, quick access via toolbar
- **Widget toolbar** — Floating buttons on the side for quick access to all widgets, position (left/right) configurable in Settings
- **Sticky note migration** — Old sticky notes are automatically migrated to the new widget system on first launch
#### Improvements
- Ko-fi support link added to the about section and `FUNDING.yml`
---
### v1.5.2 — 21.03.2026
#### Neue Features
#### New Features
- **Custom Dialog-System** — Native `confirm()` und `alert()` durch Frosted-Glass-Dialoge ersetzt (`dialog.js`)
- **Onboarding** — 6-stufiger Willkommens-Flow beim ersten Start mit Boards, Themes, Features und Backup-Hinweis
- **Backup-Reminder** — Erinnert alle 7 Tage an JSON-Export, warnt vor Datenverlust bei Browser-Reset
- **Theme-Modal** — Theme-Picker als eigenes Modal aus Settings ausgelagert, eigener Header-Button
- **Accordion-Settings** — Alle Settings-Sektionen einklappbar mit Chevron (About/Danger Zone standardmäßig zu)
- **Custom dialog system** — Native `confirm()` and `alert()` replaced with frosted glass dialogs (`dialog.js`)
- **Onboarding** — 6-step welcome flow on first launch with explanations for boards, themes, features and a backup reminder
- **Backup reminder** — Reminds every 7 days to run a JSON export and warns about data loss on browser reset
- **Theme modal** — Theme picker moved to its own modal with its own header button
- **Accordion settings** — All settings sections collapsible (About and Danger Zone closed by default)
#### Verbesserungen
#### Improvements
- Fonts von Google Fonts API auf lokale WOFF2-Dateien umgestellt (DSGVO)
- Ungenutzte Font-Dateien entfernt (~388 KB gespart)
- `innerHTML` komplett durch `createElement`/`createElementNS` ersetzt (XSS-Schutz)
- SVG-Icons via `createElementNS` statt Inline-HTML
- Drag & Drop: Inline-Styles durch CSS-Klassen ersetzt (`.drag-ghost`, `.drag-over`, `.dragging-source`)
- Suchleisten-Toggle von DATA nach BEHAVIOR verschoben
- Nicht implementiertes "Quick Save" UI-Element entfernt
- Onboarding wiederholbar über Settings → Help
- Fonts migrated from Google Fonts API to local WOFF2 files (GDPR, ~388 KB saved)
- `innerHTML` fully replaced with `createElement` and `createElementNS` (XSS protection)
- SVG icons now via `createElementNS` instead of inline HTML
- Drag & drop uses CSS classes instead of inline styles (`.drag-ghost`, `.drag-over`, `.dragging-source`)
- Search bar toggle moved from DATA to BEHAVIOR section
- Unimplemented "Quick Save" UI element removed
- Onboarding repeatable via Settings → Help
#### Opera / Opera GX
- `manifest.opera.json` hinzugefügt (MV3 mit Workaround-Skripten)
- `src/js/opera/background.js` — Tab-Management gegen Opera Speed Dial
- `src/js/opera/redirect.js` — Content Script Redirect bei `document_start`
- `manifest.opera.json` added (MV3 with workaround scripts)
- `src/js/opera/background.js` monitors tabs and redirects away from Opera Speed Dial
- `src/js/opera/redirect.js` fires as content script at `document_start`
#### Firefox
- `manifest.firefox.json` auf Manifest V3 migriert
- `browser_specific_settings` mit Gecko-ID und `data_collection_permissions`
- `manifest.firefox.json` migrated to Manifest V3
- `browser_specific_settings` with Gecko ID and `data_collection_permissions` added
#### Build & CI
- GitHub Actions: Release erstellt jetzt 3 ZIP-Pakete (Chrome, Firefox, Opera)
- Quality-Check prüft alle 3 Manifests und Opera-Ordner
- GitHub Actions release now builds 3 ZIP packages (Chrome, Firefox, Opera)
- Quality check validates all 3 manifests and the Opera folder
---
### v1.2.0 — 20.03.2026
- Projektstruktur in `src/js/`, `src/css/`, `assets/` aufgeteilt
- JS in 10 Module aufgeteilt (storage, state, themes, boards, drag, settings, search, sticky, data, app)
- Firefox-Kompatibilität (`manifest.firefox.json`, Manifest V3)
- Vivaldi bestätigt kompatibel
- Theme-Bildpfade korrigiert (Settings Preview)
- URL-Validierung bei Bookmark-Erstellung
- JSON-Import mit Board- und Bookmark-Struktur-Validierung
- XSS-Schutz: `createElement` statt `innerHTML` für Bookmarks
- Storage-Quota-Prüfung mit Warnung bei 8 MB+
- Event Delegation für Bookmark-Klicks (Performance)
- Responsive Design (Tablet 768px, Smartphone 480px)
- Sticky Note Header-Kollision behoben
- FileReader-Fehlerbehandlung für Hintergrundbild-Upload
- GitHub Actions: Security Scan, Code Quality, Release Automation
- 3 Themes ersetzt: Astronaut → Nebula, Cosmic Clock → Crescent, Void Mage → Event Horizon
- Alle Theme-Bilder lizenzrechtlich geprüft und dokumentiert
- LICENSE (CC BY-NC-SA 4.0), SECURITY.md und DISCLAIMER.md hinzugefügt
- Project structure split into `src/js/`, `src/css/` and `assets/`
- JS split into 10 modules (storage, state, themes, boards, drag, settings, search, sticky, data, app)
- Firefox compatibility (`manifest.firefox.json`, Manifest V3)
- Vivaldi confirmed compatible
- Theme image paths fixed (settings preview)
- URL validation on bookmark creation
- JSON import validates board and bookmark structure
- XSS protection: `createElement` instead of `innerHTML` for bookmarks
- Storage quota check with warning at 8 MB+
- Event delegation for bookmark clicks (performance)
- Responsive design (tablet 768px, smartphone 480px)
- Sticky note header collision fixed
- FileReader error handling for background image upload
- GitHub Actions: security scan, code quality, release automation
- 3 themes replaced: Astronaut → Nebula, Cosmic Clock → Crescent, Void Mage → Event Horizon
- All theme images checked and documented for license compliance
- LICENSE (CC BY-NC-SA 4.0), SECURITY.md and DISCLAIMER.md added
---
### v1.1.0 — 20.03.2026
- 5 neue Themes (Merchantman, Julia & Jin, SC Sunset, Hellion HUD, Hellion Energy)
- Suchleiste (Google / DuckDuckGo / Bing)
- Sticky Note Widget
- JSON Export & Import
- Datum neben der Uhr
- About / Impressum in Settings
- Board Blur-Funktion (Privat-Modus)
- Drag & Drop auf Pointer Events umgestellt
- Opera / Opera GX Kompatibilität
- 5 new themes (Merchantman, Julia & Jin, SC Sunset, Hellion HUD, Hellion Energy)
- Search bar (Google, DuckDuckGo, Bing)
- Sticky note widget
- JSON export & import
- Date next to the clock
- About / imprint in settings
- Board blur function (privacy mode)
- Drag & drop migrated to Pointer Events API
- Opera / Opera GX compatibility
---
### v1.0.0 — 20.03.2026
- Initiales Release
- Boards & Bookmarks mit Drag & Drop
- 3 Themes (Nebula, Crescent, Event Horizon)
- HTML-Import (Browser-Lesezeichen)
- Settings Panel
- Initial release
- Boards & bookmarks with drag & drop
- 3 themes (Nebula, Crescent, Event Horizon)
- HTML import (browser bookmarks)
- Settings panel
---
+63 -29
View File
@@ -1,47 +1,81 @@
# Haftungsausschluss — Hellion NewTab
# Disclaimer — Hellion NewTab
## Nutzung auf eigenes Risiko
## Use at Your Own Risk
Diese Browser-Extension wird "wie besehen" (as-is) zur Verfügung gestellt, ohne jegliche ausdrückliche oder stillschweigende Gewährleistung, einschließlich, aber nicht beschränkt auf die Gewährleistung der Marktgängigkeit, der Eignung für einen bestimmten Zweck und der Nichtverletzung von Rechten Dritter.
This browser extension is provided "as is", without warranty of any kind, express
or implied, including but not limited to the warranties of merchantability, fitness
for a particular purpose and non-infringement.
## Keine Garantie
## No Guarantee
Der Entwickler übernimmt keine Haftung für:
The developer assumes no liability for:
- Datenverlust durch fehlerhafte Speicherung, Browser-Updates oder Extension-Deinstallation
- Inkompatibilitäten mit bestimmten Browser-Versionen oder Betriebssystemen
- Schäden, die durch die Nutzung oder Nichtnutzung dieser Extension entstehen
- Verfügbarkeit oder Korrektheit von Drittanbieter-Diensten (Google Favicons API)
- Data loss caused by storage errors, browser updates or extension uninstallation
- Incompatibilities with specific browser versions or operating systems
- Damages arising from the use or inability to use this extension
- Availability or accuracy of third-party services (Google Favicons API)
## Datenspeicherung
## Data Storage
Alle Daten werden ausschließlich lokal im Browser gespeichert (`chrome.storage.local`). Es erfolgt keine Übertragung an externe Server. Der Entwickler hat keinen Zugriff auf gespeicherte Bookmarks, Einstellungen oder Notizen.
All data is stored exclusively in the local browser (`chrome.storage.local`).
No data is transmitted to external servers. The developer has no access to stored
bookmarks, settings, notes or any other user data.
**Empfehlung:** Regelmäßig JSON-Backups über die Export-Funktion erstellen.
**Recommendation:** Create regular JSON backups using the export function in Settings.
## Drittanbieter-Dienste
## No Guaranteed Updates
Diese Extension nutzt folgende externe Dienste:
This extension is maintained by a single developer in their spare time.
Continued development and updates are not guaranteed. Features may change,
projects may pause, and support is provided on a best-effort basis, not as an obligation.
| Dienst | Zweck | Datenschutz |
| --- | --- | --- |
| Google Favicons API | Bookmark-Icons laden | Es wird nur die Domain übermittelt, keine vollständige URL |
| Google Fonts | Schriftarten (Rajdhani, Inter, Cinzel) | Standardmäßige Google-Fonts-Nutzungsbedingungen |
## Third-Party Services
## Änderungen
| Service | Purpose | Privacy |
|---|---|---|
| Google Favicons API | Load bookmark icons | Only the domain is transmitted, not the full URL |
Der Entwickler behält sich das Recht vor, diese Extension jederzeit zu ändern, zu aktualisieren oder einzustellen, ohne vorherige Ankündigung.
## Trademark
## Kontakt
The name "Hellion Online Media", the associated logo and all related graphics are
the property of Florian Wathling / Hellion Online Media and may not be used without
explicit permission. The CC BY-NC-SA 4.0 license applies to the source code and
content of this project, not to trademarks or brand assets.
| | |
| --- | --- |
| **Entwickler** | Florian Wathling |
| **Unternehmen** | Hellion Online Media |
| **Web** | [hellion-media.de](https://hellion-media.de) |
| **E-Mail** | [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de) |
| **Impressum** | [hellion-media.de/impressum](https://hellion-media.de/impressum) |
Forks and derivative works must remove or replace all Hellion Online Media branding.
## Legal
This extension is developed and maintained by Florian Wathling / Hellion Online Media,
based in Bad Harzburg, Germany. All matters are handled in accordance with German
and EU law, including the General Data Protection Regulation (GDPR / DSGVO).
For legal inquiries: [hellion-media.de/impressum](https://hellion-media.de/impressum)
## Use of AI
**Claude:** Code analysis, bug fixing, documentation and proofreading.
**Me:** Architecture, features and logic are planned, thought through and written by me.
Who looks for "AI patterns" in the code: clean indentation is the linter,
okayish variable names are the developer, and the semicolon hiding somewhere
is what Claude finds. That's how it works.
I have ADHD and mild dyslexia. Claude helps me stay focused and makes sure
others can follow the code too. That's exactly what open source is for.
Source code is open, every decision is traceable.
---
**Hellion NewTab** — [Hellion Online Media - Florian Wathing](https://hellion-media.de) — JonKazama-Hellion
| | |
|---|---|
| **Developer** | Florian Wathling |
| **Company** | Hellion Online Media |
| **Web** | [hellion-media.de](https://hellion-media.de) |
| **Imprint** | [hellion-media.de/impressum](https://hellion-media.de/impressum) |
| **Contact** | [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de) |
---
**Hellion NewTab** — [Hellion Online Media — Florian Wathling](https://hellion-media.de) — JonKazama-Hellion
+208 -178
View File
@@ -1,33 +1,35 @@
# ⬡ Hellion Dashboard v1.5.2
# ⬡ Hellion Dashboard v2.0.0
![Version](https://img.shields.io/badge/Version-1.5.2-blue)
![Version](https://img.shields.io/badge/Version-2.0.0-blue)
![JavaScript](https://img.shields.io/badge/JavaScript-Vanilla%20ES2020-F7DF1E?logo=javascript&logoColor=black)
![Manifest](https://img.shields.io/badge/Manifest-V3-green)
![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-orange)
![Privacy](https://img.shields.io/badge/Privacy-100%25%20Lokal-448f45)
![Privacy](https://img.shields.io/badge/Privacy-100%25%20Local-448f45)
[![Ko-fi](https://img.shields.io/badge/Support-Ko--fi-ff5e5b?logo=ko-fi)](https://ko-fi.com/hellionmedia)
**Kein Account. Kein Abo. Keine Cloud. Alle Daten bleiben 100% lokal.**
**No account. No subscription. No cloud. All data stays 100% local.**
Ein persönlicher Bookmark-Dashboard als Browser-Extension.
Boards, Drag & Drop, 8 Themes, Suchleiste, Sticky Notes — alles im Browser, alles offline.
Keine externe Datenübertragung, keine Tracker, keine Analytics, keine Werbung.
A personal bookmark dashboard as a browser extension.
Boards, drag & drop, 11 themes, search bar, widget system with notes, calculator, timer and more.
Full DE/EN language support with runtime switching. All in the browser, all offline.
No external data transmission, no trackers, no analytics, no ads.
Entwickelt von **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion.
Developed by **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion.
---
## Was diese Extension NICHT ist
## What this extension is NOT
- Kein Cloud-Sync und kein Account-System
- Keine Datenerfassung oder Telemetrie
- Keine Drittanbieter-Abhängigkeiten oder Build-Tools
- Kein Netzwerkverkehr außer Favicon-Abruf (Google Favicons API)
- No cloud sync and no account system
- No data collection or telemetry
- No third-party dependencies or build tools
- No network traffic except favicon fetching (Google Favicons API)
## Was diese Extension IST
## What this extension IS
Ein lokaler, privater NewTab-Ersatz für alle gängigen Browser.
Bookmarks werden in `chrome.storage.local` gespeichert — nichts verlässt den Browser.
Was angezeigt wird, ist was gespeichert ist. Keine Magie.
A local, private NewTab replacement for all major browsers.
Bookmarks are stored in `chrome.storage.local`, nothing leaves the browser.
What you see is what's saved. No magic.
---
@@ -35,25 +37,30 @@ Was angezeigt wird, ist was gespeichert ist. Keine Magie.
### Boards & Bookmarks
- Boards als Gruppen für Links — per Drag & Drop umsortierbar
- Bookmarks mit Favicon, Titel, optionaler Beschreibung
- Boards per Blur-Button verstecken (Privat-Modus)
- HTML-Import von Browser-Lesezeichen (Chrome, Edge, Firefox)
- JSON Export & Import (Backup & Restore)
- Boards as groups for links, sortable via drag & drop
- Bookmarks with favicon, title and optional description
- Hide boards with the blur button (privacy mode)
- HTML import from browser bookmarks (Chrome, Edge, Firefox)
- JSON export & import (backup & restore)
### Suchleiste
### Search Bar
- Google, DuckDuckGo oder Bing — per Klick wechselbar
- Ein/ausblendbar über Settings
- Google, DuckDuckGo or Bing, switchable with a click
- Toggleable via Settings
### Sticky Note
### Widget System
- Schwebendes Notiz-Widget, frei positionierbar
- Text und Position werden persistent gespeichert
- **Notes & Checklists** — Floating note widgets with text or checklist template (max. 5)
- **Calculator** — Shunting-yard parser (no `eval()`), history, keyboard input
- **Timer / Countdown** — Saveable presets, Web Audio API alarm, mute toggle, tab title blinks on completion
- **Image Reference** — Images as floating reference widgets, Canvas API WebP conversion (max. 3, enable in Settings)
- **Notebook Sidebar** — All notes at a glance
- **Widget Toolbar** — Floating buttons for quick access, position (left/right) configurable in Settings
- All widgets: draggable, resizable, z-index stacking on click
### 8 Themes
### 11 Themes
| Theme | Akzent | Stil |
| Theme | Accent | Style |
|---|---|---|
| Nebula | `#b359ff` Magenta | Cosmic Nebula |
| Crescent | `#d4bd8a` Gold | Minimalist Night |
@@ -63,60 +70,64 @@ Was angezeigt wird, ist was gespeichert ist. Keine Magie.
| SC Sunset | `#ff8c3d` Amber | Planet-Side |
| Hellion HUD | `#32ff6a` Neon Green | Circuit Board |
| Hellion Energy | `#1eff8e` Acid Green | Tactical |
| Satisfactory | `#00b4d8` Cyan | Industrial Desert |
| Avorion | `#2ec4a0` Turquoise | Deep Void |
| Hellion Stealth | `#5ec2ff` Tech Blue | Tactical Recon |
### Bild-Credits
### Image Credits
| Theme | Quelle | Lizenz |
| Theme | Source | License |
|---|---|---|
| Nebula | [Temel / mrwashingt0n](https://pixabay.com/de/users/mrwashingt0n-15745216/) auf Pixabay | Pixabay License (frei) |
| Crescent | [Daniil Silantev](https://unsplash.com) auf Unsplash | Unsplash License (frei) |
| Event Horizon | Eigenes Werk — Stillframe von [hellion-initiative.online](https://hellion-initiative.online) | Hellion Online Media |
| Merchantman | [Roberts Space Industries](https://robertsspaceindustries.com) — Made by the community | RSI Community Content |
| SC Sunset | Screenshot aus Star Citizen von Cloud Imperium Games | Fan Content |
| Julia & Jin | Eigenes Werk — Final Fantasy XIV Screenshot, bearbeitet in Photoshop | Hellion Online Media |
| Hellion HUD | Eigenes Werk — AI-generiert und nachbearbeitet für hellion-media.de | Hellion Online Media |
| Hellion Energy | Eigenes Werk — AI-generiert für hellion-media.de | Hellion Online Media |
| Nebula | [Temel / mrwashingt0n](https://pixabay.com/de/users/mrwashingt0n-15745216/) on Pixabay | Pixabay License (free) |
| Crescent | [Daniil Silantev](https://unsplash.com) on Unsplash | Unsplash License (free) |
| Event Horizon | Own work, still frame from [hellion-initiative.online](https://hellion-initiative.online) | Hellion Online Media |
| Merchantman | [Roberts Space Industries](https://robertsspaceindustries.com), made by the community | RSI Community Content |
| SC Sunset | Screenshot from Star Citizen by Cloud Imperium Games | Fan Content |
| Julia & Jin | Own work, Final Fantasy XIV screenshot, edited in Photoshop | Hellion Online Media |
| Hellion HUD | Own work, AI-generated and post-processed for hellion-media.de | Hellion Online Media |
| Hellion Energy | Own work, AI-generated for hellion-media.de | Hellion Online Media |
| Satisfactory | Screenshot from Satisfactory by Coffee Stain Studios | Fan Content |
| Avorion | Own work, screenshot from Avorion, Hellion Initiative ship | Hellion Online Media |
| Hellion Stealth | Screenshot from Star Citizen by Cloud Imperium Games | Fan Content |
### Onboarding & Dialoge
### Language Support (i18n)
- 6-stufiger Willkommens-Flow beim ersten Start
- Custom Frosted-Glass-Dialoge statt nativer Browser-Popups
- Backup-Reminder alle 7 Tage (warnt vor Datenverlust bei Browser-Reset)
- German and English with runtime switching via Settings
- Auto-detect from browser language, manual override available
- All UI elements, dialogs, onboarding and widget labels fully translated
### Settings (Accordion)
### Onboarding & Dialogs
- Einklappbare Sektionen mit Chevron — About/Danger Zone standardmäßig geschlossen
- Compact Mode — reduziert Abstände für mehr Bookmarks
- Shorten Titles — kürzt lange Titel auf eine Zeile
- Open in New Tab — Bookmarks in neuem Tab öffnen
- Show Descriptions — Beschreibungen unter Bookmarks anzeigen
- Hide Extra Bookmarks — Boards ab 5/10/20 Bookmarks einklappen
- Suchleiste ein/ausblenden
- JSON Export / Import
- Onboarding wiederholbar
- Danger Zone — Reset aller Daten
- 7-step welcome flow on first launch with widget explanation and optional gaming starter board
- Custom frosted glass dialogs instead of native browser popups
- Backup reminder every 7 days (warns about data loss on browser reset)
### Theme-Picker (eigener Header-Button)
### Appearance & Settings
- 8 Themes als zentriertes Modal
- Hintergrundbild per URL oder lokaler Upload
- **Appearance modal** (header button), theme picker, background image and all display options in one modal
- **Settings panel** (header button), widgets, data & help, danger zone
- **About footer**, developer info, license and support links permanently visible
- Compact mode, shorten titles, search bar toggle, open links in new tab, descriptions, hide extra bookmarks
- JSON export & import (backup & restore)
- Onboarding repeatable
- Language setting: German, English or auto-detect
---
## Browser-Kompatibilität
## Browser Compatibility
| Browser | Status | Manifest |
|---|---|---|
| Chrome | ✅ Kompatibel | V3 (`manifest.json`) |
| Edge | ✅ Kompatibel | V3 (`manifest.json`) |
| Brave | ✅ Kompatibel | V3 (`manifest.json`) |
| Opera | ✅ Kompatibel | V3 (`manifest.opera.json`) |
| Opera GX | ✅ Kompatibel | V3 (`manifest.opera.json`) |
| Vivaldi | ✅ Kompatibel | V3 (`manifest.json`) |
| Firefox | ✅ Kompatibel | V3 (`manifest.firefox.json`) |
| Chrome | ✅ Compatible | V3 (`manifest.json`) |
| Edge | ✅ Compatible | V3 (`manifest.json`) |
| Brave | ✅ Compatible | V3 (`manifest.json`) |
| Opera | ✅ Compatible | V3 (`manifest.opera.json`) |
| Opera GX | ✅ Compatible | V3 (`manifest.opera.json`) |
| Vivaldi | ✅ Compatible | V3 (`manifest.json`) |
| Firefox | ✅ Compatible | V3 (`manifest.firefox.json`) |
> **Firefox-Hinweis:** Ab v1.2.0 läuft die Extension auf Manifest V3 identisch zu Chrome/Edge.
> `manifest.firefox.json` bleibt als separate Datei erhalten für Firefox-spezifische Anpassungen.
> **Firefox note:** From v1.2.0 onwards the extension runs on Manifest V3, identical to Chrome/Edge.
> `manifest.firefox.json` remains a separate file for Firefox-specific adjustments.
---
@@ -125,144 +136,159 @@ Was angezeigt wird, ist was gespeichert ist. Keine Magie.
### Chrome / Edge / Brave / Vivaldi
```text
1. Repository als ZIP herunterladen oder git clone
2. chrome://extensions öffnen (bzw. edge:// / brave://)
3. Entwicklermodus aktivieren
4. "Entpackte Erweiterung laden" → Ordner auswählen in dem manifest.json liegt
5. Neuen Tab öffnen
1. Download the repository as ZIP or git clone
2. Open chrome://extensions (or edge:// / brave://)
3. Enable developer mode
4. Click "Load unpacked" and select the folder containing manifest.json
5. Open a new tab
```
### Opera / Opera GX
```bash
# manifest.opera.json als manifest.json verwenden:
# Use manifest.opera.json as manifest.json:
copy manifest.opera.json manifest.json # Windows
cp manifest.opera.json manifest.json # Linux/Mac
```
```text
1. opera://extensions öffnen
2. Entwicklermodus aktivieren
3. "Entpackte Erweiterung laden" → Ordner auswählen
4. Neuen Tab öffnen
1. Open opera://extensions
2. Enable developer mode
3. Click "Load unpacked" and select the folder
4. Open a new tab
```
> **Opera-Hinweis:** Opera GX priorisiert Speed Dial — der enthaltene Workaround
> übernimmt die New-Tab-Seite zuverlässig. Details: [src/js/opera/README.md](src/js/opera/README.md)
> **Opera note:** Opera GX prioritizes Speed Dial, the included workaround
> takes over the new tab page reliably. Details: [src/js/opera/README.md](src/js/opera/README.md)
### Firefox
```bash
# manifest.firefox.json als manifest.json verwenden:
# Use manifest.firefox.json as manifest.json:
copy manifest.firefox.json manifest.json # Windows
cp manifest.firefox.json manifest.json # Linux/Mac
```
```text
1. about:debugging#/runtime/this-firefox öffnen
2. "Temporäres Add-on laden"
3. Die manifest.json aus dem Projektordner auswählen
1. Open about:debugging#/runtime/this-firefox
2. Click "Load Temporary Add-on"
3. Select the manifest.json from the project folder
```
> **Hinweis:** Temporäre Add-ons werden beim Browser-Neustart entfernt.
> Für dauerhafte Installation ist eine signierte `.xpi`-Datei nötig.
> **Note:** Temporary add-ons are removed on browser restart.
> For permanent installation a signed `.xpi` file is required.
---
## Browser-Bookmarks exportieren & importieren
## Importing Browser Bookmarks
| Browser | Export-Pfad |
| Browser | Export path |
|---|---|
| Chrome / Edge | Einstellungen → Lesezeichen → Exportieren |
| Firefox | Lesezeichen → Alle Lesezeichen → Importieren und Sichern → Als HTML exportieren |
| Chrome / Edge | Settings → Bookmarks → Export bookmarks |
| Firefox | Bookmarks → All Bookmarks → Import and Backup → Export Bookmarks to HTML |
Die exportierte `.html`-Datei über den **Import**-Button in der Extension laden.
Load the exported `.html` file via the **Import** button in the extension.
---
## Datenschutz
## Privacy
- Keine externe Datenübertragung (außer Google Favicons API für Icons)
- Speicherung in `chrome.storage.local` (Chromium) bzw. `browser.storage.local` (Firefox)
- Keine Tracker, keine Analytics, keine Werbung
- Keine Cookies, keine Session-Daten
- Storage-Quota-Prüfung warnt bei 8 MB+ (Limit: 10 MB)
- Permissions: `storage`, `bookmarks`
- No external data transmission (except Google Favicons API for icons)
- Storage in `chrome.storage.local` (Chromium) or `browser.storage.local` (Firefox)
- No trackers, no analytics, no ads
- No cookies, no session data
- Storage quota check warns at 8 MB+ (limit: 10 MB)
- Permissions: `storage`, `bookmarks` (all browsers) + `tabs` (Opera / Opera GX only)
---
## Tech-Stack
## Tech Stack
| Komponente | Details |
| Component | Details |
|---|---|
| Sprache | JavaScript (Vanilla ES2020, keine Frameworks) |
| Styling | CSS Custom Properties (Theme-System) |
| Fonts | Lokale Fonts (Rajdhani, Inter, Cinzel) |
| Storage | `chrome.storage.local` / `localStorage` Fallback |
| Language | JavaScript (Vanilla ES2020, no frameworks) |
| Styling | CSS Custom Properties (theme system) |
| Fonts | Local fonts (Rajdhani, Inter, Cinzel) |
| Storage | `chrome.storage.local` / `localStorage` fallback |
| Favicons | Google Favicons API (`/s2/favicons`) |
| Drag & Drop | Pointer Events API (nativ) |
| Build | Kein Build-Schritt — direkt lauffähig |
| CI/CD | GitHub Actions (Security, Quality, Release) |
| Drag & Drop | Pointer Events API (native) |
| Build | No build step, runs directly |
| CI/CD | GitHub Actions (security, quality, release) |
---
## Architektur
## Architecture
```text
hellion-newtab/
├── manifest.json # Chrome, Edge, Brave, Vivaldi (MV3)
├── manifest.firefox.json # Firefox (MV3)
├── manifest.opera.json # Opera / Opera GX (MV3 + Workaround)
├── newtab.html # Haupt-HTML (UI-Struktur, Modals, Settings Panel)
├── manifest.opera.json # Opera / Opera GX (MV3 + workaround)
├── newtab.html # Main HTML (UI structure, modals, settings panel)
├── LICENSE # CC BY-NC-SA 4.0
├── CHANGELOG.md # Versionshistorie
├── SECURITY.md # Sicherheitsrichtlinie und Meldeprozess
├── DISCLAIMER.md # Haftungsausschluss
├── CHANGELOG.md # Version history
├── SECURITY.md # Security policy and reporting
├── DISCLAIMER.md # Disclaimer and legal
├── _locales/
│ ├── de/messages.json # Manifest-level i18n (German)
│ └── en/messages.json # Manifest-level i18n (English)
├── src/
│ ├── js/
│ │ ├── storage.js # Storage Abstraction + Quota-Prüfung
│ │ ├── state.js # Globaler State, Defaults, Hilfsfunktionen
│ │ ├── dialog.js # Custom Dialog-System (HellionDialog.alert/confirm)
│ │ ├── themes.js # Theme-Definitionen & Anwendungslogik
│ │ ├── boards.js # Board/Bookmark Rendering, Event Delegation, Modals
│ │ ├── drag.js # Drag & Drop (Pointer Events, Board + Bookmark)
│ │ ├── settings.js # Settings Panel, Theme-Modal, Accordion
│ │ ├── search.js # Suchleiste (Google, DuckDuckGo, Bing)
│ │ ├── sticky.js # Sticky Note Widget (Drag, Persist, Toggle)
│ │ ├── data.js # JSON Export / Import mit Validierung
│ │ ├── onboarding.js # Mehrstufiger Willkommens-Flow
│ │ ├── app.js # Init, Clock, globale Events (Einstiegspunkt)
│ │ ── opera/ # Opera GX Workaround-Skripte
│ │ ├── background.js # Tab-Management gegen Speed Dial
│ │ └── redirect.js # Content Script Redirect
│ │ ├── storage.js # Storage abstraction + quota check
│ │ ├── state.js # Global state, defaults, helpers
│ │ ├── i18n.js # Internationalization (DE/EN, ~220+ keys, t() helper)
│ │ ├── dialog.js # Custom dialog system (HellionDialog.alert/confirm)
│ │ ├── themes.js # Theme definitions & application (11 themes)
│ │ ├── boards.js # Board/bookmark rendering, event delegation, modals
│ │ ├── drag.js # Drag & drop (Pointer Events, board + bookmark)
│ │ ├── settings.js # Settings panel, appearance modal, accordion
│ │ ├── search.js # Search bar (Google, DuckDuckGo, Bing)
│ │ ├── widgets.js # Widget manager (registry, drag, resize, z-index)
│ │ ├── notes.js # Notes & checklists (multi-instance, max. 5)
│ │ ├── calculator.js # Calculator (shunting-yard, history)
│ │ ── timer.js # Timer/countdown (presets, Web Audio alarm)
│ │ ├── image-ref.js # Image reference widget (Canvas API, sessionStorage)
│ │ ├── data.js # JSON export / import with validation
│ │ ├── onboarding.js # 7-step welcome flow + gaming board
│ │ ├── app.js # Init, clock, global events (entry point)
│ │ └── opera/ # Opera GX workaround scripts
│ │ ├── background.js # Tab management against Speed Dial
│ │ └── redirect.js # Content script redirect
│ └── css/
│ └── main.css # Styles + Theme-System + Responsive Breakpoints
│ └── main.css # Styles + 11 themes + responsive breakpoints
├── assets/
│ ├── fonts/ # Lokale Fonts (Rajdhani, Inter, Cinzel)
│ ├── themes/ # 8 Theme-Hintergrundbilder
│ ├── fonts/ # Local fonts (Rajdhani, Inter, Cinzel)
│ ├── themes/ # 11 theme background images (WebP only)
│ └── icons/
│ ├── icon16.png
│ ├── icon48.png
│ └── icon128.png
├── docs/
│ ├── architecture.md # Project architecture and init sequence
│ ├── widget-schema.md # Widget system API and schema reference
│ ├── patterns.md # Code patterns and conventions
│ └── style-guide.md # Design system and theme documentation
└── .github/
└── workflows/
├── security.yml # CodeQL-Analyse + Dependency Review
├── quality.yml # Struktur, Manifest, Syntax, Versions-Konsistenz
└── release.yml # ZIP-Pakete (Chrome + Firefox + Opera) + SHA256
├── security.yml # CodeQL analysis + dependency review
├── quality.yml # Structure, manifest, syntax, version consistency
└── release.yml # ZIP packages (Chrome + Firefox + Opera) + SHA256
```
### Design-Prinzipien
### Design Principles
- **Zero Dependencies** — Kein npm, kein Build, kein Framework. Direkt lauffähig
- **Privacy First** — Alle Daten lokal, kein Server-Kontakt
- **Modular** — 12 JS-Dateien mit klarer Zuständigkeit
- **Responsive** — Tablet (768px) und Smartphone (480px) Breakpoints
- **Secure** — `createElement` statt `innerHTML`, URL-Validierung, Storage-Fehlerbehandlung
- **Event Delegation** — Ein Listener pro Board-Liste statt pro Bookmark (Performance)
- **Theme-System** — CSS Custom Properties, 8 Themes, Custom-Background-Support
- **Zero Dependencies** — No npm, no build, no framework. Runs directly
- **Privacy First** — All data local, no server contact
- **Modular** — 16 JS files with clear responsibilities
- **Responsive** — Tablet (768px) and smartphone (480px) breakpoints
- **Secure** — `createElement` instead of `innerHTML`, URL validation, storage error handling
- **Event Delegation** — One listener per board list instead of per bookmark (performance)
- **Theme System** — CSS Custom Properties, 11 themes, custom background support
---
@@ -270,84 +296,88 @@ hellion-newtab/
### Security Scan (`security.yml`)
- **CodeQL-Analyse** — Statische Sicherheitsanalyse für JavaScript
- **Dependency Review** — Prüft Pull Requests auf bekannte Schwachstellen
- **Zeitplan** — Automatisch wöchentlich (Montag 06:00 UTC) + bei Push/PR
- **CodeQL analysis** — Static security analysis for JavaScript
- **Dependency review** — Checks pull requests for known vulnerabilities
- **Schedule** — Automatically weekly (Monday 06:00 UTC) + on push/PR
### Code Quality (`quality.yml`)
- **Projektstruktur** — Alle Pflichtdateien und -ordner vorhanden
- **Manifest-Validierung** — JSON-Syntax, Version, Permissions
- **JavaScript Syntax-Check** — `node --check` für alle JS-Dateien
- **Versions-Konsistenz** — manifest.json, manifest.firefox.json und newtab.html müssen übereinstimmen
- **Icon-Prüfung** — Alle Extension-Icons vorhanden
- **Project structure** — All required files and folders present
- **Manifest validation** — JSON syntax, version, permissions
- **JavaScript syntax check** — `node --check` for all JS files
- **Version consistency** — manifest.json, manifest.firefox.json and newtab.html must match
- **Icon check** — All extension icons present
### Release (`release.yml`)
- **Trigger** — Bei Git-Tag (`v*`)
- **Pakete** — Chrome-ZIP + Firefox-ZIP + Opera-ZIP (alle MV3)
- **Checksummen** — SHA256 für alle Artefakte
- **GitHub Release** — Automatisch mit Installationsanleitung
- **Trigger** — On Git tag (`v*`)
- **Packages** — Chrome ZIP + Firefox ZIP + Opera ZIP (all MV3)
- **Checksums** — SHA256 for all artifacts
- **GitHub Release** — Automatic with installation instructions
```bash
# Release erstellen:
git tag v1.5.2
git push origin v1.5.2
# → GitHub Action erstellt automatisch Release mit ZIP-Dateien
# Create a release:
git tag v2.0.0
git push origin v2.0.0
# → GitHub Action automatically creates release with ZIP files
```
---
## Entwicklung
## Development
```bash
# Repository klonen
# Clone the repository
git clone https://github.com/JonKazama-Hellion/Hellion-NewTab.git
# Extension im Browser laden (siehe Installation)
# Load the extension in your browser (see Installation)
# Nach Änderungen: Extension neu laden
chrome://extensions → Hellion NewTab → Neu laden
# After changes: reload the extension
chrome://extensions → Hellion NewTab → Reload
```
Kein Build-Schritt nötig. Dateien ändern, Extension neu laden, fertig.
No build step needed. Change files, reload extension, done.
---
## Sicherheit
## Security
Sicherheitslücken bitte **nicht** über öffentliche Issues melden.
Details zur Meldung, Reaktionszeiten und Sicherheitsarchitektur: [SECURITY.md](SECURITY.md)
Please do **not** report security vulnerabilities through public GitHub issues.
Details on reporting, response times and security architecture: [SECURITY.md](SECURITY.md)
---
## Lizenz & Impressum
## License & Legal
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
- Kostenlos für private Nutzung
- Teilen und Modifikation erlaubt mit Namensnennung
- Kommerzielle Nutzung ohne Erlaubnis verboten
- Free for private use
- Sharing and modification allowed with attribution
- Commercial use without permission prohibited
Vollständige Lizenz: [LICENSE](LICENSE) | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/)
Full license: [LICENSE](LICENSE) | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/)
| | |
|---|---|
| **Entwickler** | Florian Wathling |
| **Unternehmen** | Hellion Online Media |
| **Developer** | Florian Wathling |
| **Company** | Hellion Online Media |
| **Web** | [hellion-media.de](https://hellion-media.de) |
| **Impressum** | [hellion-media.de/impressum](https://hellion-media.de/impressum) |
| **Imprint** | [hellion-media.de/impressum](https://hellion-media.de/impressum) |
| **Bug Reports** | [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=Hellion%20NewTab%20%E2%80%93%20Bug%20Report) |
| **Security** | [SECURITY.md](SECURITY.md) |
| **Support** | [Ko-fi](https://ko-fi.com/hellionmedia) |
---
### Einsatz von AI
### Use of AI
AI (Claude Code, Opus 4.6 von Anthropic) wurde als Hilfsmittel eingesetzt — für Fehleridentifikation, Code-Review und Qualitätssicherung. Architektur, Features und alle Entscheidungen sind Eigenleistung.
**Claude:** Code analysis, bug fixing, documentation and proofreading.
**Me:** Architecture, features and logic are planned, thought through and written by me.
Details: [DISCLAIMER.md](DISCLAIMER.md)
---
> Vollständige Versionshistorie: [CHANGELOG.md](CHANGELOG.md)
> Full version history: [CHANGELOG.md](CHANGELOG.md)
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
+76 -60
View File
@@ -1,76 +1,92 @@
# Sicherheitsrichtlinie — Hellion NewTab
# Security Policy — Hellion NewTab
## Unterstützte Versionen
## Supported Versions
| Version | Status |
| --- | --- |
| 1.2.x | Aktiv unterstützt |
| < 1.2.0 | Nicht unterstützt |
|---|---|
| 1.9.x | Actively supported |
| < 1.9.0 | Not supported |
## Sicherheitslücke melden
## Reporting a Vulnerability
Wenn du eine Sicherheitslücke in Hellion NewTab findest, melde sie bitte **nicht** über ein öffentliches GitHub Issue.
If you find a security vulnerability in Hellion NewTab, please **do not** open a public GitHub issue.
### Kontakt
### Contact
**E-Mail:** [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=Hellion%20NewTab%20%E2%80%93%20Security%20Report)
**Email:** [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=Hellion%20NewTab%20%E2%80%93%20Security%20Report)
Bitte folgende Informationen angeben:
Please include the following information:
- Beschreibung der Schwachstelle
- Schritte zur Reproduktion
- Betroffene Version(en)
- Mögliche Auswirkungen (Datenverlust, XSS, etc.)
- Description of the vulnerability
- Steps to reproduce
- Affected version(s)
- Potential impact (data loss, XSS, etc.)
### Reaktionszeit
### Response Times
- **Bestätigung:** Innerhalb von 48 Stunden
- **Ersteinschätzung:** Innerhalb von 7 Tagen
- **Fix:** Abhängig von Schweregrad, Ziel innerhalb von 14 Tagen
- **Acknowledgement:** Within 48 hours
- **Initial assessment:** Within 7 days
- **Fix:** Depends on severity, target within 14 days
### Schweregrad-Einstufung
### Severity Levels
| Stufe | Beschreibung | Beispiel |
| --- | --- | --- |
| Kritisch | Datenverlust oder Remote Code Execution | Storage-Manipulation durch Dritte |
| Hoch | XSS oder ungewollte Datenübertragung | Script-Injection via Bookmark-Import |
| Mittel | Umgehung von UI-Schutzmechanismen | Blur-Bypass, Settings-Manipulation |
| Niedrig | Kosmetisch oder theoretisch | Edge-Cases ohne praktische Auswirkung |
## Sicherheitsarchitektur
### Datenverarbeitung
- **Keine externe Datenübertragung** — Alle Daten bleiben in `chrome.storage.local`
- **Kein Server-Kontakt** — Außer Google Favicons API für Bookmark-Icons
- **Keine Cookies, Sessions oder Tokens**
- **Kein Netzwerkzugriff** außer Favicon-Abruf
### Eingabe-Validierung
- URL-Validierung bei Bookmark-Erstellung (`new URL()`)
- JSON-Import: Board- und Bookmark-Struktur wird validiert
- HTML-Sanitierung via `escHtml()` und `createElement` (kein `innerHTML` für User-Daten)
- Storage-Quota-Prüfung mit Warnung bei 8 MB+
### Permissions
Diese Extension benötigt nur zwei Browser-Permissions:
| Permission | Grund |
| --- | --- |
| `storage` | Boards, Settings und Sticky Note lokal speichern |
| `bookmarks` | Browser-Lesezeichen für HTML-Import lesen |
Keine Permissions für: Tabs, History, Web Requests, Downloads, Clipboard oder Host-Zugriff.
### CI/CD-Sicherheit
- **CodeQL** — Automatische statische Analyse bei Push und PR
- **Dependency Review** — Prüft auf bekannte Schwachstellen in PRs
- **Wöchentlicher Scan** — Automatischer CodeQL-Lauf jeden Montag
- **SHA256-Checksummen** — Alle Release-Artefakte werden signiert
| Level | Description | Example |
|---|---|---|
| Critical | Data loss or remote code execution | Storage manipulation by third parties |
| High | XSS or unintended data transmission | Script injection via bookmark import |
| Medium | UI protection bypass | Blur bypass, settings manipulation |
| Low | Cosmetic or theoretical | Edge cases without practical impact |
---
**Hellion Dashboard** — [Hellion Online Media - Florian Wathling](https://hellion-media.de) — JonKazama-Hellion
## Security Architecture
### Data Handling
- **No external data transmission** — all data stays in `chrome.storage.local`
- **No server contact** — except Google Favicons API for bookmark icons
- **No cookies, sessions or tokens**
- **No network access** beyond favicon fetching
### Input Validation
- URL validation on bookmark creation (`new URL()`)
- JSON import validates board and bookmark structure before applying
- HTML sanitization via `escHtml()` and `createElement` — no `innerHTML` for user data
- Storage quota check with warning at 8 MB+
### Permissions
This extension requests the following browser permissions:
| Permission | Browsers | Reason |
|---|---|---|
| `storage` | All | Store boards, settings and widget states locally |
| `bookmarks` | All | Read browser bookmarks for direct import |
| `tabs` | Opera / Opera GX only | Required for the Speed Dial workaround — `background.js` monitors tab URLs and redirects via `chrome.tabs.update` |
No permissions requested for: history, web requests, downloads, clipboard or host access.
### CI/CD Security
- **CodeQL** — Automatic static analysis on every push and PR
- **Dependency Review** — Checks for known vulnerabilities in PRs
- **Weekly scan** — Automated CodeQL run every Monday at 06:00 UTC
- **SHA256 checksums** — All release artifacts are checksummed
---
## Legal
Hellion NewTab is developed and maintained by **Florian Wathling / Hellion Online Media**,
based in Bad Harzburg, Germany.
All security matters are handled in accordance with **German and EU law**, including
the General Data Protection Regulation (GDPR / DSGVO). Users in the European Union
are covered by the same legal framework.
For legal inquiries: [hellion-media.de/impressum](https://hellion-media.de/impressum)
---
**Hellion Dashboard** — [Hellion Online Media — Florian Wathling](https://hellion-media.de) — JonKazama-Hellion
+8
View File
@@ -0,0 +1,8 @@
{
"extName": { "message": "Hellion NewTab" },
"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" }
}
+8
View File
@@ -0,0 +1,8 @@
{
"extName": { "message": "Hellion NewTab" },
"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" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+316
View File
@@ -0,0 +1,316 @@
# Hellion Dashboard — Design & Theme System
> This document is intentionally written in English. Full German/English i18n support
> is planned for v2.0 — until then, English keeps the docs accessible to anyone
> who wants to contribute or fork the project.
---
## Design Pillars
| Pillar | Description |
|---|---|
| **Immersion** | The interface feels like a HUD floating over the scene, not a foreign object sitting on top of it |
| **Visual Clarity** | Deliberate use of `blur` separates UI from background and reduces visual noise and cognitive load |
| **Harmony** | Every theme pulls its colors from the dominant light sources in its background image |
---
## Background Images — WebP Only
**All background images must be in WebP format.** This is an intentional architectural
decision to keep storage quota usage predictable and leave room for future features
(widgets, image references, etc.) that also compete for the 10 MB `chrome.storage` limit.
JPG, PNG and other formats are not accepted, so convert before adding a theme.
### Recommended Settings
| Quality | When to use |
|---|---|
| 85 | Default, good balance of size and sharpness |
| 80 | For images over 500 KB |
| 90 | For images with fine details (stars, in-game UI text) |
### Conversion Tools
**Squoosh** (squoosh.app) — browser-based, no install, nothing gets uploaded to external servers.
Drag in the image, pick WebP, set quality to 85, download. Done.
**cwebp** (command line):
```bash
cwebp -q 85 input.jpg -o output.webp
```
### Current Theme Images
| File | Status |
|---|---|
| `bg-nebula.webp` | ✅ WebP |
| `bg-crescent.webp` | ✅ WebP |
| `bg-event-horizon.webp` | ✅ WebP |
| `bg-merchantman.webp` | ✅ WebP |
| `bg-julia-jin.webp` | ✅ WebP |
| `bg-sc-sunset.webp` | ✅ WebP |
| `bg-hellion-hud.webp` | ✅ WebP |
| `bg-hellion-energy.webp` | ✅ WebP |
| `bg-satisfactory.webp` | ✅ WebP |
| `bg-avorion.webp` | ✅ WebP |
| `bg-scPolaris.webp` | ✅ WebP |
---
## Anatomy of a Theme
Every theme lives in `main.css` as a `[data-theme="name"]` block. Copy this template
to add a new one:
```css
[data-theme="your-theme-name"] {
/* 1. ACCENTS — The light source */
--accent: #HEXCODE; /* Main color (neon/light) */
--accent-dim: rgba(R, G, B, 0.12); /* Subtle background tint */
--accent-glow: rgba(R, G, B, 0.08); /* Glow for logo & clock */
--border-accent: rgba(R, G, B, 0.25); /* Focus ring */
/* 2. BASE — The foundation */
--bg-primary: #HEXCODE; /* Darkest point in the image */
--bg-board: rgba(R, G, B, 0.55); /* Glass effect on boards */
--border: rgba(R, G, B, 0.12); /* Default border */
/* 3. TEXT — Contrast */
--text-primary: #FFFFFF; /* Readable, slightly tinted */
--text-secondary: #A0A0A0; /* Desaturated, less visual weight */
--text-muted: #606060; /* Barely visible, for hints */
/* 4. OVERLAY — Vignette */
--overlay-bg: radial-gradient(
circle at center,
transparent 0%,
var(--bg-primary) 100%
);
/* 5. COMPONENT COLORS */
--header-bg: rgba(R, G, B, 0.94);
--board-hover-border: rgba(R, G, B, 0.22);
--toggle-on-bg: rgba(R, G, B, 0.20);
--logo-shadow: rgba(R, G, B, 0.50);
/* 6. FONTS */
--font-display: 'Rajdhani', sans-serif;
--font-body: 'Inter', sans-serif;
}
/* Theme-specific overrides */
[data-theme="your-theme-name"] .logo { letter-spacing: 4px; }
[data-theme="your-theme-name"] .clock { color: var(--accent); }
[data-theme="your-theme-name"] .board-title { text-transform: uppercase; }
[data-theme="your-theme-name"] .board { backdrop-filter: blur(8px); }
[data-theme="your-theme-name"] .bm-item:hover { background: var(--accent-dim); }
```
After adding the CSS block, register the theme in `src/js/themes.js` and add a preview entry in the theme picker.
---
## UI Patterns
### Frosted Glass
Hardware-accelerated blur for readability on complex backgrounds:
```css
backdrop-filter: blur(8px);
```
Creates depth and visual calm behind text and UI elements. Standard value is `8px`. Only increase it when the background image has a lot of fine detail that competes with the UI.
### Clock Color
All themes set `color: var(--accent)` on the clock element. This is a consistent
detail across the entire theme system. Don't skip it for new themes.
```css
[data-theme="your-theme"] .clock { color: var(--accent); }
```
### Typography Hierarchy
| Font | Usage |
|---|---|
| **Rajdhani** | Display: clock, logo, titles. Anything that should feel like a system readout |
| **Inter** | Body: bookmark titles, lists, interactive elements |
| **Cinzel** | Fantasy: reserved for themes with a majestic or ancient aesthetic (Crescent, Julia & Jin) |
### Overlay Strategy
The overlay gradient determines what stays visible in the background image.
**Radial (default)** draws attention to the center and darkens edges:
```css
--overlay-bg: radial-gradient(circle at center, transparent 0%, var(--bg-primary) 100%);
```
**Linear** darkens top and bottom and leaves the middle open. Use when the subject
is horizontally centered and should stay visible (Satisfactory factory floor, SC Sunset horizon):
```css
--overlay-bg: linear-gradient(180deg, rgba(R,G,B,0.85) 0%, rgba(R,G,B,0.15) 50%, rgba(R,G,B,0.90) 100%);
```
Choose based on where the most important part of the image is, not by habit.
---
## Focus & Accessibility
For backgrounds with a lot of detail (many small elements, high contrast, busy textures),
increase board alpha and blur to reduce visual noise. This makes boards easier to scan,
especially for users with ADHD or attention sensitivities.
```css
--bg-board: rgba(R, G, B, 0.65); /* Up from default 0.55 */
backdrop-filter: blur(12px); /* Up from default 8px */
```
This was applied intentionally to the Satisfactory theme, because the factory floor screenshot
has a lot going on and needed more visual separation between background and UI.
---
## All 11 Themes
| Theme | File | Accent | Mood | Overlay |
|---|---|---|---|---|
| Nebula | `bg-nebula.webp` | `#b359ff` Magenta | Chill, Cosmic | Radial |
| Crescent | `bg-crescent.webp` | `#d4bd8a` Gold | Luxury, Night | Radial |
| Event Horizon | `bg-event-horizon.webp` | `#9d5cff` Purple | Deep Space, Void | Radial |
| Merchantman | `bg-merchantman.webp` | `#2eb8b8` Emerald | Industrial, Alien | Radial |
| Julia & Jin | `bg-julia-jin.webp` | `#7db3ff` Aetherial Blue | FFXIV Night | Linear |
| SC Sunset | `bg-sc-sunset.webp` | `#ff8c3d` Amber | Emotional, Horizon | Linear |
| Hellion HUD | `bg-hellion-hud.webp` | `#32ff6a` Neon Green | Tactical, Admin | Radial |
| Hellion Energy | `bg-hellion-energy.webp` | `#1eff8e` Acid Green | Overdrive, Power | Radial |
| Satisfactory | `bg-satisfactory.webp` | `#00b4d8` Cyan | Industrial Desert | Linear |
| Avorion | `bg-avorion.webp` | `#2ec4a0` Turquoise | Deep Void | Radial |
| Hellion Stealth | `bg-scPolaris.webp` | `#5ec2ff` Tech Blue | Tactical Recon | Radial |
### Theme Quirks Worth Knowing
**Julia & Jin** uses `Cinzel` as display font and a linear gradient. The subjects in
the screenshot are positioned left of center, so radial would soften them.
**Satisfactory** has increased board alpha (0.65) and stronger blur (12px), an intentional
ADHD optimization for a visually busy background.
**Avorion** uses `letter-spacing: 6px` on the logo for maximum HUD feel.
**Hellion Stealth** is the only theme with `border-left: 2px solid var(--accent)` on
`.bm-item:hover`. Every other theme uses background tinting only. This is intentional
and gives Stealth its tactical scanner character. Don't apply it to other themes.
---
## Registering a Theme in themes.js
The `THEMES` object in `src/js/themes.js` is the single source of truth for which
themes exist and which background image they use. CSS handles all the visual variables —
`themes.js` only needs the image path.
```javascript
const THEMES = {
'nebula': { bg: 'assets/themes/bg-nebula.webp' },
'crescent': { bg: 'assets/themes/bg-crescent.webp' },
'event-horizon': { bg: 'assets/themes/bg-event-horizon.webp' },
'merchantman': { bg: 'assets/themes/bg-merchantman.webp' },
'julia-jin': { bg: 'assets/themes/bg-julia-jin.webp' },
'sc-sunset': { bg: 'assets/themes/bg-sc-sunset.webp' },
'hellion-hud': { bg: 'assets/themes/bg-hellion-hud.webp' },
'hellion-energy': { bg: 'assets/themes/bg-hellion-energy.webp' },
'satisfactory': { bg: 'assets/themes/bg-satisfactory.webp' },
'avorion': { bg: 'assets/themes/bg-avorion.webp' },
'hellion-stealth': { bg: 'assets/themes/bg-scPolaris.webp' }
};
```
To add a new theme, add one line. The key must exactly match the `data-theme`
attribute in the CSS block. If they don't match, `applyTheme()` will silently
do nothing and no one will know why.
```javascript
// New theme: key must match [data-theme="your-theme-name"] in main.css
'your-theme-name': { bg: 'assets/themes/bg-your-theme.webp' }
```
### How applyTheme() works
```javascript
function applyTheme(themeName, skipBgOverride) {
const theme = THEMES[themeName];
if (!theme) return;
// Sets data-theme on <html> — activates the matching CSS variable block
document.documentElement.setAttribute('data-theme', themeName);
// Applies the background image unless a custom background is active
if (!skipBgOverride) {
document.getElementById('bgLayer').style.backgroundImage = `url('${theme.bg}')`;
}
// Updates the active state in the theme picker UI
document.querySelectorAll('.theme-card').forEach(card => {
card.classList.toggle('active', card.dataset.value === themeName);
});
}
```
The `skipBgOverride` flag exists for one specific case: when a user has set a custom
background image, switching themes should still update the CSS variables and the picker
UI, but not wipe their custom image. Pass `true` to skip the background update.
---
## Adding a Theme Card to newtab.html
The theme picker modal lives in `newtab.html` as `#themeOverlay`. Every theme
needs a card in the `.theme-grid` — without it the theme exists in CSS and JS
but never shows up in the UI.
Copy this block and add it inside `.theme-grid`, after the last existing card:
```html
<div class="theme-card" data-value="your-theme-name">
<img class="theme-card-img" src="assets/themes/bg-your-theme.webp" alt="Your Theme" />
<span class="theme-card-label">Your Theme</span>
<span class="theme-card-check"></span>
</div>
```
Three things that must match exactly:
- `data-value` must match the key in `THEMES` in `themes.js`
- `data-value` must match the `[data-theme="..."]` attribute in `main.css`
- `src` must point to the correct WebP file in `assets/themes/`
The label shown in the picker can be shorter than the full theme name — "HUD" and
"Energy" are good examples of that. Keep it short enough to fit the card.
The `active` class is toggled by `applyTheme()` automatically, so don't add it
manually unless you want that theme to be the default on first load (Nebula currently
has it as fallback).
---
## Adding a New Theme — Checklist
- [ ] Background image converted to WebP (quality 85)
- [ ] Image added to `assets/themes/`
- [ ] CSS block added to `src/css/main.css`
- [ ] Theme registered in `src/js/themes.js` (one line, key + bg path)
- [ ] Theme card added to `.theme-grid` in `newtab.html` (data-value, img src, label)
- [ ] Theme added to theme table in `README.md`
- [ ] Theme added to theme table in this document
- [ ] Image credit added to Bild-Credits table in `README.md`
- [ ] `CHANGELOG.md` entry added
---
Developed by **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion
+175
View File
@@ -0,0 +1,175 @@
# Hellion Dashboard — Architecture
> This document is intentionally written in English. Full German/English i18n support
> is planned for v2.0 — until then, English keeps the docs accessible to anyone
> who wants to contribute or fork the project.
---
## Overview
Hellion Dashboard is a browser extension (NewTab replacement) built with **Vanilla JavaScript ES2020**, **CSS Custom Properties**, and **zero dependencies**. No build step, no framework, no bundler — files are loaded directly via `<script>` tags.
**Storage:** `chrome.storage.local` with `localStorage` fallback.
**Manifest:** V3 across all supported browsers (separate files for Firefox and Opera GX).
---
## File Structure
```
HOM_NewTab_Project/
├── newtab.html # Single HTML entry point
├── manifest.json # Chrome, Edge, Brave, Vivaldi (MV3)
├── manifest.firefox.json # Firefox (MV3)
├── manifest.opera.json # Opera, Opera GX (MV3 + workarounds)
├── src/
│ ├── css/
│ │ └── main.css # All styles, 11 themes, responsive breakpoints
│ └── js/
│ ├── storage.js # Storage abstraction layer
│ ├── state.js # Global state, defaults, helpers
│ ├── i18n.js # Internationalization (DE/EN, t() helper)
│ ├── dialog.js # Custom dialog system (alert, confirm)
│ ├── themes.js # Theme definitions & application (11 themes)
│ ├── drag.js # Drag & drop (Pointer Events API)
│ ├── boards.js # Board/bookmark rendering & events
│ ├── settings.js # Settings panel, toggles, theme picker
│ ├── search.js # Search bar (Google, DuckDuckGo, Bing)
│ ├── widgets.js # Widget manager (registry, drag, resize)
│ ├── notes.js # Notes/checklists (multi-instance widgets)
│ ├── calculator.js # Calculator widget (single-instance)
│ ├── timer.js # Timer/countdown widget (single-instance)
│ ├── image-ref.js # Image reference widget (multi-instance)
│ ├── bookmark-import.js # Browser bookmark import (chrome.bookmarks API)
│ ├── data.js # JSON export/import (backup & restore)
│ ├── onboarding.js # First-run onboarding flow
│ └── app.js # Init, clock, global events (entry point)
├── assets/
│ ├── fonts/ # Local fonts (Rajdhani, Inter, Cinzel)
│ ├── icons/ # Extension icons (16-512px)
│ └── themes/ # 11 theme background images
└── docs/ # You are here
```
---
## Module Responsibilities
Each module has exactly one responsibility. Communication happens through global references — no import/export, because this is a browser extension without a bundler.
| Module | Responsibility |
|---|---|
| `storage.js` | The **only** place that touches `chrome.storage` / `localStorage`. Everything else goes through `Store.get()` / `Store.set()`. |
| `state.js` | Global `boards` and `settings` arrays, default values, `uid()`, `escHtml()`, `getFaviconUrl()`. |
| `i18n.js` | Internationalization module. `STRINGS` object with ~220+ keys (DE/EN), `t(key, vars?)` helper, `applyLanguage()` DOM scanner, `setLanguage()`, `I18n.init()`. |
| `dialog.js` | `HellionDialog.alert()` and `HellionDialog.confirm()` — custom styled dialogs that replace native browser popups. |
| `themes.js` | Applies theme CSS variables. 11 themes, each with its own `[data-theme]` block in `main.css`. |
| `boards.js` | Renders boards and bookmarks. Event delegation on board containers. |
| `drag.js` | Board and bookmark reordering via Pointer Events API. |
| `settings.js` | Settings panel UI, toggle handlers, appearance modal, background upload. |
| `search.js` | Search bar with engine switching (Google, DuckDuckGo, Bing). |
| `widgets.js` | Widget manager — creates DOM, handles drag/resize/z-index, provides registry. See [widget-schema.md](widget-schema.md). |
| `notes.js` | Notes and checklists as widgets. Multi-instance (max 5). Notebook sidebar. Also handles widget toolbar events. |
| `calculator.js` | Calculator widget. Single-instance. Shunting-yard expression parser — no `eval()`. |
| `timer.js` | Timer/countdown widget. Single-instance. Presets, Web Audio API alarm, tab-title blink on completion. |
| `image-ref.js` | Image reference widget. Multi-instance (max 3). Canvas API WebP conversion, sessionStorage for image data — cleared on browser close. |
| `bookmark-import.js` | Direct browser bookmark import via `chrome.bookmarks.getTree()`. Folder selection modal with duplicate detection. |
| `data.js` | JSON export/import with validation. Covers boards, notes, calculator history and timer presets. |
| `onboarding.js` | Multi-slide first-run flow including the gaming starter board opt-in. |
| `app.js` | Entry point. Calls `init()` on DOMContentLoaded. Clock, global event binding. |
---
## Init Sequence
```
DOMContentLoaded
→ init()
→ Store.get('boards') # Load saved boards
→ Store.get('settings') # Load saved settings
→ applySettings() # Apply theme, toggles, etc.
→ renderBoards() # Render all boards
→ startClock() # Start clock/date display
→ bindGlobalEvents() # Header buttons, modals
→ bindSettingsEvents() # Settings toggles, theme picker
→ initSearch() # Search bar
→ migrateSticky() # Legacy sticky note migration (v1.5.x → v1.6+)
→ Notes.init() # Notes + widget toolbar
→ Calculator.init() # Calculator widget
→ Timer.init() # Timer widget
→ ImageRef.init() # Image reference widget
→ initDataButtons() # Export/import buttons
→ Onboarding check # First-run onboarding
```
---
## Script Load Order
Scripts are loaded in `newtab.html` in dependency order. A module may only reference modules loaded before it — there is no bundler to handle this automatically.
```html
<script src="src/js/storage.js"></script>
<script src="src/js/state.js"></script>
<script src="src/js/i18n.js"></script>
<script src="src/js/dialog.js"></script>
<script src="src/js/themes.js"></script>
<script src="src/js/drag.js"></script>
<script src="src/js/boards.js"></script>
<script src="src/js/settings.js"></script>
<script src="src/js/search.js"></script>
<script src="src/js/widgets.js"></script>
<script src="src/js/notes.js"></script>
<script src="src/js/calculator.js"></script>
<script src="src/js/timer.js"></script>
<script src="src/js/image-ref.js"></script>
<script src="src/js/bookmark-import.js"></script>
<script src="src/js/data.js"></script>
<script src="src/js/onboarding.js"></script>
<script src="src/js/app.js"></script>
```
---
## Z-Index Hierarchy
| Layer | z-index | Elements |
|---|---|---|
| Background | 0-2 | `#bgLayer`, boards |
| Search bar | 90 | `.search-bar-wrapper` |
| Widgets + Toolbar | 100+ | `.widget`, `.widget-toolbar` |
| Header | 100 | `#header` |
| Settings panel | 200 | `#settingsPanel` |
| Dialogs / Modals | 300 | `.hellion-dialog-overlay`, modals |
| Onboarding | 400 | `#onboardingOverlay` |
Widgets use an incrementing z-index (`WidgetManager._topZ++`) so the last clicked widget always sits on top.
---
## Storage Keys
| Key | Type | Content |
|---|---|---|
| `boards` | Array | Board objects with bookmarks |
| `settings` | Object | User preferences (theme, toggles, etc.) |
| `widgetStates` | Object | All widget data — see [widget-schema.md](widget-schema.md) |
| `onboardingDone` | Boolean | Whether the first-run onboarding has been completed |
| `lastBackupReminder` | Number | Timestamp of the last backup reminder |
---
## Browser Compatibility
| Browser | Engine | Manifest |
|---|---|---|
| Chrome | Chromium MV3 | `manifest.json` |
| Edge | Chromium MV3 | `manifest.json` |
| Brave | Chromium MV3 | `manifest.json` |
| Vivaldi | Chromium MV3 | `manifest.json` |
| Opera / GX | Chromium MV3 | `manifest.opera.json` |
| Firefox | Gecko MV3 | `manifest.firefox.json` |
Any change that touches manifest fields — version numbers, permissions, content scripts —
needs to be applied to all three files. The CI quality check will catch it if they drift out of sync.
+308
View File
@@ -0,0 +1,308 @@
# Hellion Dashboard — Code Patterns & Conventions
> This document is intentionally written in English. Full German/English i18n support
> is planned for v2.0 — until then, English keeps the docs accessible to anyone
> who wants to contribute or fork the project.
---
## Core Principles
- **Vanilla JS ES2020** — No frameworks, no TypeScript, no build step
- **Zero dependencies** — Everything is built from scratch
- **`createElement` only** — Never use `innerHTML` (XSS prevention)
- **CSS Custom Properties** — No hardcoded colors, everything through `var(--name)`
- **Event delegation** — One listener per container, not per element
- **Storage abstraction** — All storage access through `Store.get()` / `Store.set()`
---
## Pattern: Storage Abstraction
**File:** `src/js/storage.js`
All persistent data goes through the `Store` object. Never access `chrome.storage` or `localStorage` directly — `Store` handles the fallback between the two transparently and provides unified error handling when storage is full.
```javascript
// Reading
const boards = await Store.get('boards'); // Returns null if not found
const settings = await Store.get('settings');
// Writing
await Store.set('boards', boards);
await Store.set('settings', settings);
// Quota check (chrome.storage only, 10 MB limit)
await Store.checkQuota();
```
---
## Pattern: Event Delegation
One listener on the container, `closest()` to find the target. Much cleaner than attaching a listener to every single element, and it works automatically for dynamically added content.
```javascript
// GOOD — one listener, handles all bookmarks
container.addEventListener('click', (e) => {
const bmItem = e.target.closest('.bm-item');
if (!bmItem) return;
const id = bmItem.dataset.id;
// Handle click
});
// BAD — listener per element
bookmarks.forEach(bm => {
bm.addEventListener('click', handler); // Don't do this!
});
```
Used in `boards.js` (board/bookmark events), `notes.js` (toolbar) and `calculator.js` (button grid).
---
## Pattern: createElement over innerHTML
Always build DOM with `document.createElement()`. This is the project's #1 security rule — `innerHTML` with user-provided content is an XSS risk, full stop.
```javascript
// GOOD
const link = document.createElement('a');
link.href = bookmark.url;
link.textContent = bookmark.title;
container.appendChild(link);
// BAD — XSS risk!
container.innerHTML = `<a href="${url}">${title}</a>`;
```
---
## Pattern: Shared Storage Key
All widget modules share the `widgetStates` storage key. Every module that writes to it must read first and preserve what's already there — otherwise modules silently overwrite each other's data.
```javascript
async save() {
const data = await Store.get('widgetStates') || {};
// Write your own data
data.yourKey = { /* ... */ };
// Don't replace the whole object — other modules live here too
await Store.set('widgetStates', data);
}
```
See [widget-schema.md](widget-schema.md) for the full `widgetStates` structure.
---
## Pattern: Widget Lifecycle Hooks
Single-instance widgets (Calculator, Timer) need to react when they're closed, minimized, or reopened. They do this by wrapping `WidgetManager` methods in their `init()`.
```javascript
async init() {
const prevClose = WidgetManager.close;
const self = this;
WidgetManager.close = function(id) {
prevClose.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self.onClose();
}
};
const prevMinimize = WidgetManager.minimize;
WidgetManager.minimize = async function(id) {
await prevMinimize.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = false;
await self.save();
}
};
}
```
Multiple widgets chain these wraps — Calculator wraps first, Timer wraps Calculator's already-wrapped version, and so on. Always call the previous method (`prevClose.call(...)`) or the chain breaks and other widgets stop responding.
---
## Pattern: Debounced Save
For frequent updates like typing in notes or dragging widgets, debouncing avoids hammering storage with a write on every keystroke.
```javascript
_saveTimer: null,
_debouncedSave() {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => this.save(), 500);
}
// Use _debouncedSave() instead of save() for frequent events
textarea.addEventListener('input', () => {
noteData.content = textarea.value;
this._debouncedSave();
});
```
Used in `notes.js` (text editing) and `image-ref.js` (label editing).
---
## Pattern: Theme System
All themes use CSS Custom Properties in `[data-theme="name"]` blocks in `main.css`. There are currently 11 themes.
```css
[data-theme="nebula"] {
--bg-primary: #0a0e17;
--bg-board: rgba(15, 20, 35, 0.65);
--text-primary: #e0e6f0;
--accent: #7db3ff;
--border: rgba(125, 179, 255, 0.12);
/* ... more variables */
}
```
Never hardcode colors in JS. Let CSS handle it.
```javascript
// GOOD
element.classList.add('active');
// BAD — breaks every theme that isn't Nebula
element.style.color = '#7db3ff';
```
---
## Pattern: Onboarding Slides
The onboarding system in `onboarding.js` is data-driven. Each slide is a plain object — add a new slide by adding an object to the `slides` array, the `_render()` method handles the rest.
```javascript
{
hero: '🎮', // Large emoji/icon
title: 'Slide Title',
text: 'Optional description',
features: ['Item 1', ...], // Optional bullet list
showThemes: true, // Optional theme grid
interactive: 'gaming-board' // Optional custom buttons
}
```
---
## Pattern: Dialog System
Custom dialogs replace native `alert()` and `confirm()` everywhere in the project.
```javascript
// Informational
await HellionDialog.alert('Message text', {
type: 'info', // 'info', 'success', 'warning', 'danger'
title: 'Title'
});
// Yes/no
const ok = await HellionDialog.confirm('Are you sure?', {
type: 'danger',
title: 'Delete',
confirmText: 'Delete',
cancelText: 'Cancel'
});
if (ok) { /* user confirmed */ }
```
---
## Pattern: Pointer Events for Drag
Widget dragging and board reordering use the Pointer Events API instead of mouse events. The reason: Pointer Events work with both mouse and touch, and `setPointerCapture` keeps the events flowing even if the cursor leaves the element mid-drag.
```javascript
element.addEventListener('pointerdown', (e) => {
element.setPointerCapture(e.pointerId);
function onMove(ev) {
// Update position
}
function onUp() {
element.releasePointerCapture(e.pointerId);
element.removeEventListener('pointermove', onMove);
element.removeEventListener('pointerup', onUp);
}
element.addEventListener('pointermove', onMove);
element.addEventListener('pointerup', onUp);
});
```
---
## Pattern: Canvas API Image Processing
The image reference widget converts uploaded images to WebP locally in the browser — no external service, no upload, nothing leaves the device.
```javascript
_processFile(file) {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const webpUrl = canvas.toDataURL('image/webp', 0.85);
URL.revokeObjectURL(objectUrl); // Always free the object URL
resolve(webpUrl);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Image could not be loaded'));
};
img.src = objectUrl;
});
}
```
Always call `URL.revokeObjectURL()` after the image has loaded — skipping it leaks memory.
---
## Coding Rules Summary
| Rule | Rationale |
|---|---|
| `createElement` only, never `innerHTML` | XSS prevention |
| All storage through `Store` | Browser compatibility + unified error handling |
| CSS variables, no hardcoded colors | Theme support across all 11 themes |
| Event delegation | Performance, works with dynamic content |
| `const`/`let`, never `var` | Block scoping |
| No external dependencies | Extension simplicity |
| No build step | Direct development, no toolchain to break |
| JSDoc comments on public functions | Documentation for contributors |
| URL validation before `href` | Security |
| Error handling on storage operations | Graceful failure |
| `URL.revokeObjectURL()` after Canvas ops | Memory management |
---
## Manifest Synchronization
Three manifest files must always stay in sync:
- `manifest.json` — Chrome, Edge, Brave, Vivaldi
- `manifest.firefox.json` — Firefox
- `manifest.opera.json` — Opera, Opera GX
Version numbers, permissions and content script entries need to be updated in all three. The CI quality check will catch drift, but it's cleaner not to let it get there in the first place.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,955 @@
# Hellion NewTab v2.0.1 Hardening — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Harden v2.0.0 with security fixes, widget event-system refactoring, i18n completeness, and code quality improvements.
**Architecture:** Foundation-First — build the new widget event system first, then migrate widget modules onto it, then layer security, i18n, and quality fixes. Each task touches isolated files to avoid merge conflicts.
**Tech Stack:** Vanilla JavaScript ES2020, CSS Custom Properties, Browser Extension Manifest V3, no build step, no npm.
**Spec:** `docs/superpowers/specs/2026-04-16-hardening-v2.0.1-design.md`
**Testing:** No automated test framework. Each task includes manual browser-based verification steps. Load the extension in Chrome (`chrome://extensions` → Developer mode → Load unpacked) after each task.
---
## File Map
| File | Tasks | Changes |
|---|---|---|
| `src/js/widgets.js` | 1, 2 | Add event system (`_emitter`, `on`, `off`), dispatch events in `close`/`minimize`/`openWidget`, replace `setTimeout` with `transitionend` |
| `src/js/calculator.js` | 3 | Replace monkey-patching (L692-728) with `WidgetManager.on()` listeners |
| `src/js/timer.js` | 3 | Replace monkey-patching (L723-758) with `WidgetManager.on()` listeners |
| `src/js/image-ref.js` | 3 | Replace monkey-patching (L463-498) with `WidgetManager.on()` listeners |
| `src/js/settings.js` | 4 | Add `isValidBgUrl()`, validate in `applySettings()` and file upload + URL input handlers |
| `src/js/data.js` | 5 | Add `isSafeUrl()`, immutable mapping, string length limits, Notes import via `Notes.init()` |
| `src/js/state.js` | 6 | Remove `getFaviconUrl()` |
| `src/js/boards.js` | 6 | Replace `<img>` favicon with local letter-div |
| `src/css/main.css` | 6, 7 | Replace `.bm-favicon`/`.bm-favicon-fallback` with `.bm-favicon-local`, add `@supports not` fallback, add `--bg-solid-fallback` per theme |
| `newtab.html` | 8 | Add 5x `data-i18n-title`, 3x `data-i18n` |
| `src/js/i18n.js` | 8 | Add 10 new keys to `STRINGS.de` and `STRINGS.en` (8 i18n + 2 bgUrl validation) |
| `src/js/app.js` | 9 | Store `setInterval` ID in variable |
| `manifest.json` | 9 | Version bump to 2.0.1 |
| `manifest.firefox.json` | 9 | Version bump to 2.0.1 |
| `manifest.opera.json` | 9 | Version bump to 2.0.1 |
| `CHANGELOG.md` | 9 | Add v2.0.1 entry |
---
### Task 1: Widget Event-System in WidgetManager
**Files:**
- Modify: `src/js/widgets.js:6-10` (add emitter + on/off)
- Modify: `src/js/widgets.js:143-148` (close — dispatch event)
- [ ] **Step 1: Add event emitter and on/off methods to WidgetManager**
In `src/js/widgets.js`, add three new properties after `STORAGE_KEY: 'widgetStates',` (line 10):
```javascript
/** @type {EventTarget} Internes Event-System fuer Widget-Lifecycle */
_emitter: new EventTarget(),
/**
* Event-Listener registrieren
* @param {string} event - z.B. 'widget:close', 'widget:minimize', 'widget:open'
* @param {Function} handler
*/
on(event, handler) {
this._emitter.addEventListener(event, handler);
},
/**
* Event-Listener entfernen
* @param {string} event
* @param {Function} handler
*/
off(event, handler) {
this._emitter.removeEventListener(event, handler);
},
```
- [ ] **Step 2: Dispatch `widget:close` event in close()**
Replace the `close` method (lines 143-148):
```javascript
close(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry.el.remove();
this._widgets.delete(id);
this._emitter.dispatchEvent(new CustomEvent('widget:close', { detail: { id } }));
},
```
Note: The event fires AFTER `el.remove()` and `_widgets.delete()`. Listeners must not access the widget entry.
- [ ] **Step 3: Verify event system loads without errors**
Reload the extension in the browser. Open the console (`F12`). Verify:
- No JavaScript errors on load
- `WidgetManager.on` is a function (type `WidgetManager.on` in console)
- `WidgetManager._emitter` is an EventTarget
- [ ] **Step 4: Commit**
```bash
git add src/js/widgets.js
git commit -m "refactor(widgets): add EventTarget-based lifecycle event system
Add _emitter, on(), off() to WidgetManager. Dispatch widget:close event
after close(). Foundation for removing monkey-patching from widget modules."
```
---
### Task 2: Minimize with transitionend + openWidget event dispatch
**Files:**
- Modify: `src/js/widgets.js:154-163` (minimize)
- Modify: `src/js/widgets.js:169-180` (openWidget)
- [ ] **Step 1: Replace setTimeout with transitionend in minimize()**
Replace the `minimize` method (lines 154-163):
```javascript
async minimize(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry.state.open = false;
entry._minimizing = true;
entry.el.classList.add('widget-minimized');
entry.el.addEventListener('transitionend', function onEnd(e) {
if (e.target !== entry.el) return;
entry.el.removeEventListener('transitionend', onEnd);
if (entry._minimizing) {
entry.el.style.display = 'none';
}
entry._minimizing = false;
});
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
await this.save();
},
```
- [ ] **Step 2: Add race-condition guard and event dispatch to openWidget()**
Replace the `openWidget` method (lines 169-180):
```javascript
async openWidget(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry._minimizing = false;
entry.state.open = true;
entry.el.style.display = 'flex';
requestAnimationFrame(() => {
entry.el.classList.remove('widget-minimized');
});
this.bringToFront(id);
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
await this.save();
},
```
Key change: `entry._minimizing = false` cancels any in-flight minimize transition.
- [ ] **Step 3: Verify minimize/open animation works**
Reload extension. Test:
1. Create a note → minimize it → verify it fades out and disappears
2. Click the note in the widget toolbar to reopen → verify it appears smoothly
3. Rapid test: minimize → immediately reopen before animation ends → verify no display glitch (the race condition fix)
- [ ] **Step 4: Commit**
```bash
git add src/js/widgets.js
git commit -m "fix(widgets): replace setTimeout with transitionend in minimize
Fixes race condition where openWidget() during the 250ms timeout would
be overridden. Uses _minimizing flag to cancel in-flight transitions.
Dispatches widget:minimize and widget:open events."
```
---
### Task 3: Migrate Calculator, Timer, ImageRef to Event Listeners
**Files:**
- Modify: `src/js/calculator.js:692-728`
- Modify: `src/js/timer.js:723-758`
- Modify: `src/js/image-ref.js:463-498`
- [ ] **Step 1: Replace monkey-patching in calculator.js**
Replace lines 692-728 (the three monkey-patching blocks in `init()`) with:
```javascript
// Widget-Lifecycle-Events
const self = this;
WidgetManager.on('widget:close', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self.onClose();
}
});
WidgetManager.on('widget:minimize', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self._isOpen = false;
self.save();
}
});
WidgetManager.on('widget:open', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self._isOpen = true;
const body = WidgetManager.getBody(self.WIDGET_ID);
if (body && body.children.length === 0) {
self.renderBody(body);
}
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
if (entry) self._bindKeyboard(entry.el);
self.save();
}
});
```
- [ ] **Step 2: Replace monkey-patching in timer.js**
Replace lines 723-758 (the three monkey-patching blocks in `init()`) with:
```javascript
// Widget-Lifecycle-Events
const self = this;
WidgetManager.on('widget:close', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self.onClose();
}
});
WidgetManager.on('widget:minimize', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self._isOpen = false;
self.save();
}
});
WidgetManager.on('widget:open', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self._isOpen = true;
const body = WidgetManager.getBody(self.WIDGET_ID);
if (body && body.children.length === 0) {
self.renderBody(body);
}
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
if (entry) self._bindKeyboard(entry.el);
self.save();
}
});
```
- [ ] **Step 3: Replace monkey-patching in image-ref.js**
Replace lines 463-498 (the three monkey-patching blocks in `init()`) with:
```javascript
// Widget-Lifecycle-Events
const self = this;
WidgetManager.on('widget:close', (e) => {
const isImage = self._images.some(img => img.id === e.detail.id);
if (isImage) {
self.onClose(e.detail.id);
}
});
WidgetManager.on('widget:minimize', (e) => {
const isImage = self._images.some(img => img.id === e.detail.id);
if (isImage) {
self.save();
}
});
WidgetManager.on('widget:open', (e) => {
const imgData = self._images.find(img => img.id === e.detail.id);
if (imgData) {
const body = WidgetManager.getBody(e.detail.id);
if (body && body.children.length === 0) {
const dataUrl = self._getSessionImage(e.detail.id);
self.renderBody(imgData, body, dataUrl);
}
self.save();
}
});
```
- [ ] **Step 4: Verify all three widget types work**
Reload extension. Test each widget type:
1. **Calculator:** Open → type a calculation → minimize → reopen → verify history is still there → close → reopen from toolbar
2. **Timer:** Open → set a time → minimize → reopen → verify time is preserved → close
3. **Image-Ref:** Enable in Settings → open image widget → add an image → minimize → reopen → verify image displays → close
Check console for any errors during all operations.
- [ ] **Step 5: Commit**
```bash
git add src/js/calculator.js src/js/timer.js src/js/image-ref.js
git commit -m "refactor(widgets): migrate Calculator, Timer, ImageRef to event listeners
Replace monkey-patching of WidgetManager.close/minimize/openWidget with
WidgetManager.on() event listeners. Eliminates 3-deep closure chain."
```
---
### Task 4: Security — URL Validation in settings.js
**Files:**
- Modify: `src/js/settings.js:52-95` (applySettings)
- Modify: `src/js/settings.js:166-175` (btnApplyBg handler)
- Modify: `src/js/settings.js:181-194` (bgFileInput handler)
- [ ] **Step 1: Add isValidBgUrl() helper**
Add this function at the top of `settings.js`, after the `closeThemeModal()` function (after line 24):
```javascript
/**
* Prueft ob eine Background-URL sicher fuer CSS-Einbettung ist.
* Erlaubt nur blob: und data:image/ Protokolle (aus File Upload).
* @param {string} url
* @returns {boolean}
*/
function isValidBgUrl(url) {
return typeof url === 'string' && url.length > 0 &&
(url.startsWith('blob:') || url.startsWith('data:image/'));
}
```
- [ ] **Step 2: Add validation in applySettings()**
Replace lines 92-94:
```javascript
if (settings.bgUrl) {
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
}
```
With:
```javascript
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
} else if (settings.bgUrl) {
// Ungueltige URL im Storage — bereinigen
settings.bgUrl = '';
}
```
- [ ] **Step 3: Add validation in the URL-input handler (btnApplyBg)**
Replace lines 169-175:
```javascript
document.getElementById('btnApplyBg').addEventListener('click', async () => {
const url = document.getElementById('bgUrlInput').value.trim();
settings.bgUrl = url;
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
await saveSettings();
document.getElementById('bgInputRow').classList.add('hidden');
});
```
With:
```javascript
document.getElementById('btnApplyBg').addEventListener('click', async () => {
const url = document.getElementById('bgUrlInput').value.trim();
if (url && !isValidBgUrl(url)) {
await HellionDialog.alert(t('settings.bg_invalid_url'), { type: 'danger', title: t('settings.bg_invalid_url.title') });
return;
}
settings.bgUrl = url;
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
await saveSettings();
document.getElementById('bgInputRow').classList.add('hidden');
});
```
- [ ] **Step 4: Verify the file upload handler is already safe**
Read `settings.js:181-194`. The `FileReader.readAsDataURL(file)` produces a `data:image/...` string, which passes `isValidBgUrl()`. The handler at line 186 sets `settings.bgUrl = ev.target.result` — this is already valid output. No change needed here.
- [ ] **Step 5: Add i18n keys for the validation error dialog**
These keys will be added in Task 8 together with all other i18n keys. For now, note that we need:
- `settings.bg_invalid_url` — "Nur lokale Bilder (Upload) sind als Hintergrund erlaubt." / "Only local images (upload) are allowed as background."
- `settings.bg_invalid_url.title` — "Ungültige URL" / "Invalid URL"
- [ ] **Step 6: Verify background upload still works**
Reload extension. Test:
1. Open Theme Modal → upload a local image → verify it displays as background
2. Try entering `javascript:alert(1)` in the URL input → verify it's rejected with a dialog
3. Reload → verify the uploaded background persists
- [ ] **Step 7: Commit**
```bash
git add src/js/settings.js
git commit -m "fix(security): validate background URL before CSS injection
Add isValidBgUrl() that only allows blob: and data:image/ protocols.
Applied in applySettings() and the manual URL input handler.
Prevents CSS injection via manipulated bgUrl storage values."
```
---
### Task 5: Security + Quality — Data Import Hardening
**Files:**
- Modify: `src/js/data.js:33-127`
- [ ] **Step 1: Add isSafeUrl() helper at top of data.js**
Add after the `initDataButtons` function declaration (after line 6, before the function body):
Actually, add it inside the function before the event listeners, right after `if (!btnExport || !btnImport) return;` (after line 10):
```javascript
/**
* Prueft ob eine URL ein sicheres Protokoll hat.
* Blockiert javascript:, data:, vbscript: etc.
* @param {string} url
* @returns {boolean}
*/
function isSafeUrl(url) {
try {
const u = new URL(url);
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
} catch {
return false;
}
}
```
- [ ] **Step 2: Replace the mutable board/bookmark filter with immutable mapping**
Replace lines 41-52 (the `validBoards` filter block):
```javascript
const validBoards = data.boards
.filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
.map(b => ({
id: b.id || uid(),
title: String(b.title).slice(0, 100),
blurred: !!b.blurred,
bookmarks: b.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)
}))
}));
```
- [ ] **Step 3: Replace the mutable notes filter with immutable mapping**
Replace lines 68-71 (the `importNotes` filter):
```javascript
const importNotes = data.notes
.filter(n => n && n.id && n.template)
.map(n => ({
id: n.id,
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
title: String(n.title || '').slice(0, 200),
content: String(n.content || '').slice(0, 5000),
x: typeof n.x === 'number' ? n.x : 120,
y: typeof n.y === 'number' ? n.y : 80,
width: typeof n.width === 'number' ? n.width : 280,
height: typeof n.height === 'number' ? n.height : 220,
open: n.open !== false,
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
}));
```
- [ ] **Step 4: Replace direct Notes._notes mutation with Notes.init()**
Replace lines 76-81:
```javascript
if (toImport.length > 0) {
const merged = [...existingNotes, ...toImport];
existingWidgets.notes = merged;
Notes._notes = merged;
notesImported = toImport.length;
}
```
With:
```javascript
if (toImport.length > 0) {
const merged = [...existingNotes, ...toImport];
existingWidgets.notes = merged;
notesImported = toImport.length;
}
```
Then after line 113 (`await Store.set('widgetStates', existingWidgets);`), add:
```javascript
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
if (notesImported > 0) await Notes.init();
if (calcImported) await Calculator.load();
if (timerImported) await Timer.load();
```
And remove the direct mutations at lines 93 and 107:
- Remove: `Calculator._history = existingWidgets.calculator.history;` (line 93)
- Remove: `Timer._presets = existingWidgets.timer.presets;` (line 107)
- [ ] **Step 5: Verify import functionality**
Reload extension. Test:
1. Export current data as JSON
2. Edit the exported JSON: add a bookmark with `javascript:alert(1)` URL → import → verify the bad bookmark is silently skipped
3. Import a normal JSON backup → verify boards, notes, calculator history, timer presets all appear correctly
4. Verify no console errors
- [ ] **Step 6: Commit**
```bash
git add src/js/data.js
git commit -m "fix(security): harden JSON import with URL validation and immutable mapping
Add isSafeUrl() to block javascript:/data: URLs in imported bookmarks.
Replace mutable object mutation with immutable .map() and string length limits.
Use Notes.init()/Calculator.load()/Timer.load() instead of direct _notes/_history
mutation after import."
```
---
### Task 6: Remove Google Favicons — Local Letter Icons
**Files:**
- Modify: `src/js/state.js:36-43` (remove `getFaviconUrl`)
- Modify: `src/js/boards.js:218-230` (replace favicon rendering)
- Modify: `src/css/main.css:565-571` (replace CSS classes)
- [ ] **Step 1: Remove getFaviconUrl() from state.js**
Delete lines 36-43 in `src/js/state.js`:
```javascript
function getFaviconUrl(url) {
try {
const u = new URL(url);
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=16`;
} catch {
return '';
}
}
```
- [ ] **Step 2: Replace favicon rendering in boards.js**
Replace lines 218-230 in `src/js/boards.js`:
```javascript
const favicon = document.createElement('img');
favicon.className = 'bm-favicon';
favicon.width = 14;
favicon.height = 14;
favicon.src = getFaviconUrl(bm.url);
favicon.addEventListener('error', function() {
this.classList.add('hidden');
this.nextElementSibling.classList.remove('hidden');
});
const fallback = document.createElement('div');
fallback.className = 'bm-favicon-fallback hidden';
fallback.textContent = bm.title.charAt(0).toUpperCase();
```
With:
```javascript
const favicon = document.createElement('div');
favicon.className = 'bm-favicon-local';
favicon.textContent = bm.title.charAt(0).toUpperCase();
const hue = (bm.title.charCodeAt(0) * 137) % 360;
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
```
Also update the `appendChild` calls below. The old code appends both `favicon` and `fallback`:
Find the line that appends the fallback (should be near line 243-244):
```javascript
li.append(favicon, fallback, textDiv, deleteBtn);
```
Replace with:
```javascript
li.append(favicon, textDiv, deleteBtn);
```
- [ ] **Step 3: Replace CSS classes in main.css**
Replace lines 565-571:
```css
.bm-favicon { width: 14px; height: 14px; flex-shrink: 0; border-radius: 2px; opacity: 0.85; }
.bm-favicon-fallback {
width: 14px; height: 14px; flex-shrink: 0;
background: var(--accent-dim); border-radius: 2px;
display: flex; align-items: center; justify-content: center;
font-size: 8px; color: var(--accent);
}
```
With:
```css
.bm-favicon-local {
width: 16px; height: 16px; flex-shrink: 0;
border-radius: 3px;
display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 600;
color: #fff;
line-height: 1;
}
```
- [ ] **Step 4: Verify favicons display correctly**
Reload extension. Check:
1. All bookmarks show a colored letter icon
2. Different bookmark titles produce different colors
3. The icons are aligned and properly sized in all themes
4. No network requests to google.com in the Network tab (F12 → Network)
5. No console errors about `getFaviconUrl`
- [ ] **Step 5: Commit**
```bash
git add src/js/state.js src/js/boards.js src/css/main.css
git commit -m "feat(privacy): replace Google Favicons with local letter icons
Remove getFaviconUrl() and all external network requests. Bookmarks now
show a colored letter icon with deterministic hue based on title.
Eliminates privacy leak and Brave Shields compatibility issues."
```
---
### Task 7: backdrop-filter Fallback for Brave Shields
**Files:**
- Modify: `src/css/main.css` (add `--bg-solid-fallback` per theme + `@supports not` block)
- [ ] **Step 1: Add --bg-solid-fallback to each theme**
Add the variable to each theme's `[data-theme]` block. The value is an opaque version of `--bg-board`:
| Theme | Line | `--bg-solid-fallback` value |
|---|---|---|
| nebula | ~82 | `#0a060e` |
| crescent | ~108 | `#0c0b08` |
| event-horizon | ~137 | `#06040f` |
| merchantman | ~163 | `#040d0d` |
| julia-jin | ~189 | `#080c12` |
| sc-sunset | ~216 | `#0e0808` |
| hellion-hud | ~245 | `#04080c` |
| hellion-energy | ~278 | `#040a08` |
| satisfactory | ~310 | `#060a0c` |
| avorion | ~341 | `#040c0a` |
| hellion-stealth | ~371 | `#060a0e` |
Add `--bg-solid-fallback: <value>;` as the last variable in each theme block.
- [ ] **Step 2: Add @supports not block at the end of the general layout section**
Add after the existing board/widget styles, before the theme-specific sections (around line 75, before the first `[data-theme]` block):
```css
/* Fallback fuer Browser die backdrop-filter blockieren (z.B. Brave Shields) */
@supports not (backdrop-filter: blur(1px)) {
.board,
.widget,
.settings-panel,
.dialog-box,
.theme-modal,
.search-bar {
background-color: var(--bg-solid-fallback, var(--bg-primary));
}
}
```
- [ ] **Step 3: Verify fallback works**
Test in Brave with Shields set to aggressive. Or test by temporarily adding this CSS rule:
```css
.board { backdrop-filter: none !important; }
```
Verify that boards still have a visible background (opaque, not transparent).
- [ ] **Step 4: Commit**
```bash
git add src/css/main.css
git commit -m "fix(compat): add backdrop-filter fallback for Brave Shields
Add --bg-solid-fallback CSS variable to all 11 themes and a
@supports not (backdrop-filter) block. UI remains usable when
Brave Shields or strict fingerprinting settings block backdrop-filter."
```
---
### Task 8: Complete i18n Coverage
**Files:**
- Modify: `newtab.html:26-42` (add `data-i18n-title` to 5 header buttons)
- Modify: `newtab.html:198, 215, 374` (add `data-i18n` to 3 setting buttons)
- Modify: `src/js/i18n.js` (add 10 new keys — 8 from spec + 2 from Task 4)
- [ ] **Step 1: Add data-i18n-title to header buttons in newtab.html**
Line 26 — change:
```html
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)">
```
To:
```html
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)" data-i18n-title="header.import_title">
```
Line 30 — change:
```html
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen">
```
To:
```html
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen" data-i18n-title="header.board_title">
```
Line 34 — change:
```html
<button class="btn-icon" id="btnNote" title="Schnellnotiz">
```
To:
```html
<button class="btn-icon" id="btnNote" title="Schnellnotiz" data-i18n-title="header.note_title">
```
Line 38 — change:
```html
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme">
```
To:
```html
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme" data-i18n-title="header.theme_title">
```
Line 42 — change:
```html
<button class="btn-icon" id="btnSettings" title="Einstellungen">
```
To:
```html
<button class="btn-icon" id="btnSettings" title="Einstellungen" data-i18n-title="header.settings_title">
```
- [ ] **Step 2: Add data-i18n to settings buttons in newtab.html**
Line 198 — change:
```html
<button class="btn-small" id="btnRestartOnboarding">Start</button>
```
To:
```html
<button class="btn-small" id="btnRestartOnboarding" data-i18n="settings.onboarding_btn">Start</button>
```
Line 215 — change:
```html
<button class="btn-danger" id="btnResetAll">Reset</button>
```
To:
```html
<button class="btn-danger" id="btnResetAll" data-i18n="settings.reset_btn">Reset</button>
```
Line 374 — change:
```html
<button class="btn-small" id="btnBgFile">Upload</button>
```
To:
```html
<button class="btn-small" id="btnBgFile" data-i18n="settings.bg_upload_btn">Upload</button>
```
- [ ] **Step 3: Add new keys to STRINGS.de in i18n.js**
Add these keys to the `STRINGS.de` object, in the appropriate sections:
In the Header section:
```javascript
'header.import_title': 'Bookmarks importieren (HTML)',
'header.board_title': 'Neues Board hinzufügen',
'header.note_title': 'Schnellnotiz',
'header.theme_title': 'Darstellung & Theme',
'header.settings_title': 'Einstellungen',
```
In the Settings section:
```javascript
'settings.onboarding_btn': 'Start',
'settings.reset_btn': 'Reset',
'settings.bg_upload_btn': 'Upload',
'settings.bg_invalid_url': 'Nur lokale Bilder (Upload) sind als Hintergrund erlaubt.',
'settings.bg_invalid_url.title': 'Ungültige URL',
```
- [ ] **Step 4: Add new keys to STRINGS.en in i18n.js**
Add the matching English keys to `STRINGS.en`:
In the Header section:
```javascript
'header.import_title': 'Import bookmarks (HTML)',
'header.board_title': 'Add new board',
'header.note_title': 'Quick note',
'header.theme_title': 'Appearance & Theme',
'header.settings_title': 'Settings',
```
In the Settings section:
```javascript
'settings.onboarding_btn': 'Start',
'settings.reset_btn': 'Reset',
'settings.bg_upload_btn': 'Upload',
'settings.bg_invalid_url': 'Only local images (upload) are allowed as background.',
'settings.bg_invalid_url.title': 'Invalid URL',
```
- [ ] **Step 5: Verify translations**
Reload extension. Test:
1. Set language to English → hover over header buttons → verify English tooltips
2. Set language to German → hover → verify German tooltips
3. Open Settings → verify "Start", "Reset", "Upload" buttons have `data-i18n` attributes (inspect in DevTools)
- [ ] **Step 6: Commit**
```bash
git add newtab.html src/js/i18n.js
git commit -m "fix(i18n): complete missing translations for toolbar tooltips and button texts
Add data-i18n-title to 5 header buttons, data-i18n to 3 settings buttons.
Add 10 new keys to STRINGS.de and STRINGS.en including background URL
validation error messages."
```
---
### Task 9: Version Bump, Changelog, Clock Cleanup
**Files:**
- Modify: `src/js/app.js:135`
- Modify: `manifest.json:5`
- Modify: `manifest.firefox.json` (version field)
- Modify: `manifest.opera.json` (version field)
- Modify: `CHANGELOG.md`
- [ ] **Step 1: Store clock interval ID in app.js**
Replace line 135 in `src/js/app.js`:
```javascript
setInterval(tick, 1000);
```
With:
```javascript
const clockInterval = setInterval(tick, 1000);
```
- [ ] **Step 2: Bump version in all three manifests**
In `manifest.json`, `manifest.firefox.json`, and `manifest.opera.json`, change:
```json
"version": "2.0.0",
```
To:
```json
"version": "2.0.1",
```
- [ ] **Step 3: Add CHANGELOG entry**
Add this block at the top of `CHANGELOG.md`, after the header and before the v2.0.0 entry:
```markdown
### v2.0.1 — 16.04.2026
#### Security
- **Background URL validation** — Only `blob:` and `data:image/` protocols allowed in CSS `backgroundImage` (prevents CSS injection via manipulated storage)
- **Import URL validation** — `javascript:`, `data:`, and other unsafe protocols are blocked during JSON import
- **Immutable import mapping** — Imported boards, bookmarks, and notes are sanitized with explicit field selection and string length limits
#### Fixed
- **Widget minimize race condition** — Replaced `setTimeout` with `transitionend` event; `openWidget()` during animation no longer causes display glitch
- **Notes import mutation** — Import now uses `Notes.init()` instead of directly setting `Notes._notes`
- **Complete i18n coverage** — 5 header button tooltips and 3 settings button texts now have `data-i18n` attributes (10 new translation keys)
#### Changed
- **Widget event system** — `WidgetManager` now dispatches `widget:close`, `widget:minimize`, `widget:open` CustomEvents via `EventTarget`. Calculator, Timer, and ImageRef use `WidgetManager.on()` instead of monkey-patching
- **Local favicon icons** — Replaced Google Favicons API with local colored letter icons (deterministic hue per title). Zero external network requests, Brave Shields compatible
- **backdrop-filter fallback** — `@supports not (backdrop-filter)` block with `--bg-solid-fallback` per theme for Brave Shields compatibility
- **Clock interval cleanup** — `setInterval` ID stored in variable
---
```
- [ ] **Step 4: Verify everything**
Full manual test:
1. Reload extension
2. Verify version in `chrome://extensions` shows 2.0.1
3. Open/close/minimize/reopen widgets of all types
4. Switch language DE/EN — all tooltips translate
5. Import/export JSON data
6. Upload background image
7. Check Network tab — zero external requests
8. Check Console — zero errors
- [ ] **Step 5: Commit**
```bash
git add src/js/app.js manifest.json manifest.firefox.json manifest.opera.json CHANGELOG.md
git commit -m "chore(release): bump version to v2.0.1 — hardening release
Security fixes, widget event system, local favicons, i18n completeness,
backdrop-filter fallback, code quality improvements. See CHANGELOG.md."
```
@@ -0,0 +1,581 @@
# Hellion NewTab — Calculator Upgrade Design
**Datum:** 2026-04-16
**Autor:** Florian Wathling / Claude Code
**Status:** Approved
**Scope:** Calculator erweitern um Scientific, Unit-Converter und Game-Rechner (Satisfactory, Factorio, Stationeers)
**Ziel-Version:** v2.1.0
---
## Kontext
Der Calculator ist aktuell ein reiner Grundrechenarten-Taschenrechner (720 Zeilen, Shunting-Yard Parser, 4x5 Button-Grid, History). Das Upgrade macht ihn zum zentralen Tool-Widget mit 6 Modi:
1. **Standard** (bestehend)
2. **Scientific** (Wurzel, Potenz, Pi, Formel-Helfer)
3. **Unit-Converter** (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche)
4. **Satisfactory** (Items/Min, Overclock-Power, Maschinen-Rechner)
5. **Factorio** (Assembler-Ratios, Belt-Throughput, Maschinen-Rechner)
6. **Stationeers** (Idealgas, Furnace/Verbrennung, Solar/Batterie, Atmosphäre)
---
## Sektion 1: Architektur und Dateistruktur
### Datei-Aufteilung
```
src/js/
├── calculator.js # Core: Tab-System, Standard-Modus, erweiterter Shunting-Yard Parser
├── calc-scientific.js # Scientific-Modus
├── calc-converter.js # Unit-Converter
├── calc-satisfactory.js # Satisfactory Calculator
├── calc-factorio.js # Factorio Calculator
└── calc-stationeers.js # Stationeers Calculator
```
### Load-Order in newtab.html
```
... → widgets.js → notes.js → calculator.js → calc-scientific.js → calc-converter.js →
calc-satisfactory.js → calc-factorio.js → calc-stationeers.js → timer.js → ...
```
Alle Mode-Dateien laden nach `calculator.js` und vor `timer.js`. Kein zirkulärer Dependency-Konflikt.
### Registrierungs-Pattern
Jede Mode-Datei registriert sich beim Calculator-Objekt:
```javascript
Calculator.registerMode('scientific', {
label: '\uD83D\uDCD0', // Icon
shortName: 'Sci', // Tab-Label (3 Zeichen)
titleKey: 'calculator.tab.scientific', // i18n-Key
render(bodyEl) { /* UI aufbauen */ },
destroy() { /* Cleanup, Event-Listener entfernen */ }
});
```
`calculator.js` bekommt:
```javascript
_modes: new Map(),
_activeMode: 'standard',
registerMode(name, config) {
this._modes.set(name, config);
},
```
Die Tab-Leiste wird dynamisch aus `_modes` gebaut. Standard-Modus ist immer registriert (intern, nicht per externer Datei). Die anderen Modi kommen dazu wenn ihre Script-Datei geladen ist.
### Tab-Wechsel
```javascript
switchMode(name) {
const mode = this._modes.get(name);
if (!mode) return;
this._activeMode = name;
const body = WidgetManager.getBody(this.WIDGET_ID);
if (!body) return;
// Alten Modus aufräumen
const oldMode = this._modes.get(this._previousMode);
if (oldMode && oldMode.destroy) oldMode.destroy();
// Neuen Modus rendern
body.textContent = '';
mode.render(body);
// Tab-UI aktualisieren
this._updateTabBar();
// State speichern
this.save();
}
```
### Storage
Jeder Modus speichert seinen State als Sub-Key unter `calculator` im bestehenden `widgetStates`:
```javascript
{
calculator: {
x: 400, y: 120, width: 320, height: 480,
open: true,
activeMode: 'standard',
history: [{ expr: '42 × 7', result: '294' }],
converter: { lastCategory: 'length', fromUnit: 'cm', toUnit: 'in' },
satisfactory: { lastSubMode: 'itemsPerMin' },
factorio: { lastSubMode: 'ratio', lastAssembler: 'asm3' },
stationeers: { lastSubMode: 'gas' }
}
}
```
Read-before-write Pattern bleibt: `const data = await Store.get(this.STORAGE_KEY) || {};`
---
## Sektion 2: Standard-Modus (Änderungen)
### Parser-Erweiterung
Der Shunting-Yard Parser wird um zwei Operationen erweitert:
**Potenz-Operator `^`:**
- Binärer Operator mit höchster Precedence (über `*` und `/`)
- Rechts-assoziativ: `2^3^2` = `2^(3^2)` = 512
- Tokenizer erkennt `^` als `{ type: 'op', value: '^' }`
- parseFactor() → parsePower() → parseFactor() (neue Precedence-Stufe)
**Wurzel-Funktion `sqrt`:**
- Wird vom Scientific-Modus als `sqrt(` in die Expression eingefügt
- Tokenizer erkennt `sqrt` als `{ type: 'func', value: 'sqrt' }`
- parseFactor() prüft auf Functions vor Numbers
Die bestehende Operator-Hierarchie wird:
```
parseExpr: + -
parseTerm: * / %
parsePower: ^ ← NEU
parseFactor: number | (expr) | func(expr) ← func NEU
```
### Keine Änderungen am Standard-UI
Das 4x5 Button-Grid, History-Panel und Keyboard-Support bleiben identisch. Die Parser-Erweiterung ist rückwärtskompatibel (keine bestehende Expression bricht).
---
## Sektion 3: Scientific-Modus
### Zusätzliche Buttons
2 neue Reihen über dem Standard-Grid:
| Button | Wert | Aktion |
|---|---|---|
| √ | `sqrt(` | Unäre Funktion, öffnet Klammer |
| x² | `^2` | Hängt `^2` an Expression |
| xⁿ | `^` | Fügt Potenz-Operator ein |
| π | `3.14159265359` | Konstante einfügen |
| e | `2.71828182846` | Konstante einfügen |
| ± | toggle | Vorzeichen des letzten Werts wechseln |
Darunter das Standard 4x5-Grid (C, Klammern, %, ÷, 0-9, Operatoren, =). Der Scientific-Modus nutzt den gleichen `_handleKey()`/`_calculate()`-Flow.
### Formel-Helfer
Ein Dropdown unter dem Button-Grid mit vorgefertigten Formeln:
| Formel | Eingabefelder | Berechnung |
|---|---|---|
| Kreis-Fläche | Radius (r) | × r²` |
| Kreis-Umfang | Radius (r) | `2 × π × r` |
| °C → °F | Temperatur | `(C × 9/5) + 32` |
| °F → °C | Temperatur | `(F - 32) × 5/9` |
| Pythagoras | a, b | `√(a² + b²)` |
| Prozent-Wert | Wert, Prozent | `Wert × Prozent / 100` |
Jede Formel öffnet inline Eingabefelder + Live-Ergebnis. Nutzt `_formatResult()` für einheitliche Zahlenformatierung.
### Keyboard
Gleicher Keyboard-Support wie Standard-Modus, plus:
- `p` → Pi einfügen
- `e` → Euler einfügen (kein Konflikt: `e` ist im Standard nicht belegt, nur `c`/`C` und `Escape` sind Clear)
---
## Sektion 4: Unit-Converter
### UI-Aufbau
```
┌──────────────────────────┐
│ [Kategorie-Dropdown ▼]│
│ │
│ [123.45 ] [cm ▼] │
│ ⇅ (Swap-Button) │
│ [48.622 ] [in ▼] │
│ │
│ Schnellreferenz: │
│ 1 cm = 0.3937 in │
│ 1 in = 2.54 cm │
└──────────────────────────┘
```
### Kategorien und Einheiten
| Kategorie | Einheiten | Basis-Einheit |
|---|---|---|
| Länge | mm, cm, m, km, in, ft, yd, mi | m |
| Gewicht | mg, g, kg, t, oz, lb | g |
| Temperatur | °C, °F, K | (Spezialfunktionen) |
| Volumen | ml, L, m³, gal(US), gal(UK), ft³ | ml |
| Geschwindigkeit | m/s, km/h, mph, kn | m/s |
| Fläche | mm², cm², m², km², ha, acre, ft², in² | m² |
### Konvertierungs-Logik
Jede Einheit hat `toBase(value)` und `fromBase(value)`:
```javascript
const LENGTH_UNITS = {
mm: { toBase: v => v / 1000, fromBase: v => v * 1000 },
cm: { toBase: v => v / 100, fromBase: v => v * 100 },
m: { toBase: v => v, fromBase: v => v },
km: { toBase: v => v * 1000, fromBase: v => v / 1000 },
in: { toBase: v => v * 0.0254, fromBase: v => v / 0.0254 },
ft: { toBase: v => v * 0.3048, fromBase: v => v / 0.3048 },
yd: { toBase: v => v * 0.9144, fromBase: v => v / 0.9144 },
mi: { toBase: v => v * 1609.344, fromBase: v => v / 1609.344 }
};
```
Temperatur bekommt eigene Funktionen (nicht linear):
```javascript
const TEMP_CONVERSIONS = {
'C_F': v => (v * 9/5) + 32,
'C_K': v => v + 273.15,
'F_C': v => (v - 32) * 5/9,
'F_K': v => (v - 32) * 5/9 + 273.15,
'K_C': v => v - 273.15,
'K_F': v => (v - 273.15) * 9/5 + 32
};
```
### Verhalten
- Live-Update bei Eingabe (kein "Berechnen"-Button)
- Swap-Button (⇅) tauscht Quell- und Ziel-Einheit
- Schnellreferenz zeigt `1 [from] = x [to]` und umgekehrt
- Kein Keyboard-Override (native `<input>` Felder)
### Storage
```javascript
converter: { lastCategory: 'length', fromUnit: 'cm', toUnit: 'in' }
```
---
## Sektion 5: Satisfactory Calculator
### Sub-Modi
Drei Buttons oben wählen den aktiven Rechner:
#### 5a: Items/Min
**Eingabefelder:**
- Items per Craft (default: 1)
- Craft Time in Sekunden (default: 4)
- Clock Speed in % (default: 100)
**Formel:**
```
Output = (ItemsPerCraft × 60) / CraftTime × (ClockSpeed / 100)
```
**Ausgabe:** `X.XX items/min`
#### 5b: Overclock Power
**Eingabefelder:**
- Base Power in MW (default: 30)
- Clock Speed in % (default: 100)
**Formeln:**
```
PowerUsage = BasePower × (ClockSpeed / 100) ^ 1.321928
EnergyPerItem = (ClockSpeed / 100) ^ 0.321928
```
**Ausgabe:**
- `Power Usage: X.X MW`
- `Efficiency: ↓ X.X% per item` (nur bei ClockSpeed > 100)
#### 5c: Maschinen
**Eingabefelder:**
- Target Output/Min (default: 60)
- Items per Craft (default: 1)
- Craft Time in Sekunden (default: 4)
- Clock Speed in % (default: 100)
- Base Power in MW (default: 30)
**Formeln:**
```
ItemsPerMin = (ItemsPerCraft × 60) / CraftTime × (ClockSpeed / 100)
Machines = ceil(TargetOutput / ItemsPerMin)
TotalPower = Machines × BasePower × (ClockSpeed / 100) ^ 1.321928
```
**Ausgabe:**
- `Machines needed: X`
- `Total Power: X.X MW`
### Verhalten
Alle Felder berechnen live. `<input type="number">` mit `step`-Attribut für sinnvolle Schrittweiten.
---
## Sektion 6: Factorio Calculator
### Sub-Modi
#### 6a: Assembler-Ratio
**Eingabefelder:**
- Assembler-Dropdown: Assembler 1 (0.5), Assembler 2 (0.75), Assembler 3 (1.25)
- Recipe Output Count (default: 1)
- Recipe Time in Sekunden (default: 1)
**Formel:**
```
OutputPerSecond = RecipeOutput × CraftingSpeed / RecipeTime
OutputPerMinute = OutputPerSecond × 60
```
**Ausgabe:**
- `X.XX items/s`
- `X.XX items/min`
#### 6b: Belt-Throughput
**Eingabefelder:**
- Belt-Dropdown: Yellow (15/s), Red (30/s), Blue (45/s)
- Items consumed per second per machine (default: 1)
**Feste Werte:**
| Belt | Total (items/s) | Per Side (items/s) |
|---|---|---|
| Yellow | 15 | 7.5 |
| Red | 30 | 15 |
| Blue | 45 | 22.5 |
**Formel:**
```
MachinesPerBelt = floor(BeltThroughput / ItemsConsumedPerSec)
Utilization = (ItemsConsumedPerSec × MachinesPerBelt) / BeltThroughput × 100
```
**Ausgabe:**
- `Machines per belt: X`
- `Belt utilization: X%`
#### 6c: Maschinen
**Eingabefelder:**
- Assembler-Dropdown
- Target Output/s (default: 10)
- Recipe Output Count (default: 1)
- Recipe Time in Sekunden (default: 1)
**Formel:**
```
OutputPerMachine = RecipeOutput × CraftingSpeed / RecipeTime
Machines = ceil(TargetOutput / OutputPerMachine)
TotalThroughput = Machines × OutputPerMachine
BeltNeeded = kleinster Belt der TotalThroughput schafft
```
**Ausgabe:**
- `Machines needed: X`
- `Belt needed: [Color] (X% utilization)`
---
## Sektion 7: Stationeers Calculator
### Sub-Modi
Vier Buttons oben (statt drei wie bei den anderen Game-Rechnern).
#### 7a: Gas (Idealgas PV=nRT)
**Eingabefelder:**
- Dropdown: Gesucht = P, V, n oder T
- Die drei anderen Variablen als Eingabefelder
**Konstante:** R = 8314.46261815324 (Stationeers-spezifisch, Einheit: L·Pa / mol·K)
**Formeln:**
```
P = nRT / V
V = nRT / P
n = PV / RT
T = PV / nR
```
**Eingabe-Einheiten:**
- P in kPa (wird intern × 1000 zu Pa)
- V in Litern
- T in Kelvin (Hilfstext zeigt °C-Äquivalent)
- n in mol
#### 7b: Furnace / Verbrennung
**Eingabefelder:**
- Fuel Ratio (0 bis 1, Anteil Brennstoff am Gesamtgas)
- Start-Temperatur in Kelvin
- Start-Druck in kPa
**Formeln:**
```
T_nach = (T_vor × specificHeat + fuel × 563452) / (specificHeat + fuel × 172.615)
P_nach = P_vor × T_nach × (1 + 5.7 × fuel) / T_vor
```
Wobei:
- `fuel = min(ratioO2, ratioVolatile / 2)`
- `specificHeat` = gewichtete Summe der Gas-Wärmekapazitäten
- Vereinfachung: Fuel Ratio als einzelner Wert (0-1), `specificHeat(before)` wird aus reinem Fuel berechnet (61.9 J/mol·K für 1:2 O₂:H₂ Mischung)
- 563452 = Energie pro Mol bei 95% Effizienz
- 172.615 = 0.95 × (243.6 - 61.9)
**Validierung:**
- Warnung wenn Fuel < 0.05 (unter 5% Minimum)
- Warnung wenn Start-Druck < 10 kPa
**Ausgabe:**
- `T after ignition: X K (X °C)`
- `P after ignition: X kPa`
#### 7c: Solar / Batterie
**Eingabefelder:**
- Anzahl Panels (default: 12)
- Watt pro Panel (default: 500, Mond-Wert)
- Tag-Länge in Sekunden (default: 600)
- Nacht-Länge in Sekunden (default: 600)
- Verbrauch in Watt (default: 2000)
**Formeln:**
```
Generation = Panels × WattsPerPanel
Surplus = Generation - Consumption
NightEnergy = Consumption × NightLength (in Watt-Sekunden)
BatteriesNeeded = ceil(NightEnergy / 50000) (Station Battery = 50.000 Ws)
```
**Ausgabe:**
- `Generation: X W`
- `Surplus: X W` (rot wenn negativ)
- `Night Energy: X Ws`
- `Batteries needed: X`
#### 7d: Atmosphäre / Gas-Mischer
**Eingabefelder:**
- Target Temperatur in Kelvin
- Gas 1 Temperatur in Kelvin
- Gas 2 Temperatur in Kelvin
**Formel:**
```
M1 = |T2 - T0| / (|T1 - T0| + |T2 - T0|)
M2 = 1 - M1
```
**Ausgabe:**
- `Mixer Input 1: X.X%`
- `Mixer Input 2: X.X%`
**Aufklappbare Wärmekapazität-Referenz:**
| Gas | Cp (J/mol·K) |
|---|---|
| O₂ | 21.1 |
| H₂ | 20.4 |
| CO₂ | 28.2 |
| N₂ | 20.6 |
| H₂O | 72.0 |
| N₂O | 23.0 |
| Pollutant | 24.8 |
---
## Sektion 8: UI, i18n und Widget-Sizing
### Tab-Leiste
Horizontale Leiste direkt unter dem Widget-Header. Immer sichtbar (kein Scrollen).
| Tab | Icon | Label |
|---|---|---|
| Standard | 🔢 | Std |
| Scientific | 📐 | Sci |
| Converter | ⚖️ | Unit |
| Satisfactory | ⚙️ | SAT |
| Factorio | 🏭 | FAC |
| Stationeers | 🚀 | STA |
Aktiver Tab: `border-bottom: 2px solid var(--accent)`, Text in `var(--accent)`.
Inaktive Tabs: `color: rgba(255,255,255,0.5)`.
CSS-Klasse: `.calc-tab-bar` und `.calc-tab`.
### Widget-Sizing
- Standard-Modus Minimum: 280 × 400 px
- Komplexe Modi (Scientific, Game-Rechner): Auto-Resize auf 320 × 480 px (falls aktuell kleiner)
- User-Resize überschreibt Auto-Resize
- Widget-System-Minimum bleibt 200 × 150 px
### i18n
Geschätzt ~100 neue Keys in `STRINGS.de` und `STRINGS.en`:
- 6 Tab-Labels
- 6 Kategorie-Namen (Converter)
- ~48 Einheiten-Langformen (Converter)
- ~30 Feld-Labels (Game-Rechner)
- ~10 Ergebnis-Labels
Einheiten-Abkürzungen (cm, kg, °C, kPa) werden nicht übersetzt.
### Keyboard
- Standard-Modus: Bestehender Keyboard-Support (0-9, +, -, *, /, Enter, Backspace, Escape)
- Scientific-Modus: Gleicher Support + `p` (Pi), `^` (Potenz)
- Converter und Game-Modi: Kein Custom-Keyboard (native `<input>` Felder)
---
## Betroffene Dateien (Gesamt)
| Datei | Änderung |
|---|---|
| `src/js/calculator.js` | Tab-System, registerMode(), switchMode(), Parser-Erweiterung (^, sqrt) |
| `src/js/calc-scientific.js` | NEU: Scientific-Modus |
| `src/js/calc-converter.js` | NEU: Unit-Converter |
| `src/js/calc-satisfactory.js` | NEU: Satisfactory Calculator |
| `src/js/calc-factorio.js` | NEU: Factorio Calculator |
| `src/js/calc-stationeers.js` | NEU: Stationeers Calculator |
| `src/css/main.css` | Tab-Bar Styles, Mode-spezifische Styles |
| `src/js/i18n.js` | ~100 neue Keys (DE + EN) |
| `newtab.html` | 5 neue `<script>` Tags in Load-Order |
| `manifest.json` | Version → 2.1.0 |
| `manifest.firefox.json` | Version → 2.1.0 |
| `manifest.opera.json` | Version → 2.1.0 |
| `CHANGELOG.md` | v2.1.0 Eintrag |
## Implementierungsreihenfolge
1. **Calculator Core** — Tab-System, registerMode(), switchMode(), Tab-Bar CSS
2. **Parser-Erweiterung**`^` Operator und `sqrt` Funktion
3. **Scientific-Modus** — Buttons, Formel-Helfer, Registrierung
4. **Unit-Converter** — Kategorien, Einheiten, Konvertierungs-Logik, UI
5. **Satisfactory Calculator** — 3 Sub-Modi, Formeln, UI
6. **Factorio Calculator** — 3 Sub-Modi, Formeln, UI
7. **Stationeers Calculator** — 4 Sub-Modi, Formeln, UI
8. **i18n** — Alle neuen Keys (DE + EN)
9. **Version Bump** — Manifests, CHANGELOG
@@ -0,0 +1,400 @@
# Hellion NewTab v2.0.1 — Hardening Release Design
**Datum:** 2026-04-16
**Autor:** Florian Wathling / Claude Code
**Status:** Approved
**Scope:** Security, Stability, i18n, Code Quality
**Strategie:** Foundation First (Event-System zuerst, dann darauf aufbauen)
---
## Kontext
Umfassender Audit von v2.0.0 hat Findings in vier Kategorien ergeben:
- 3 Sicherheitslücken (HOCH)
- 2 Stabilitätsprobleme (Race Conditions)
- 8 fehlende i18n-Attribute
- 3 Code-Qualität-Items
Dieses Design beschreibt alle Fixes als zusammenhängendes Hardening-Release.
---
## Sektion 1: Widget Event-System
### Problem
Calculator (`calculator.js:692-728`), Timer (`timer.js:723-758`) und ImageRef (`image-ref.js:463-498`) überschreiben `WidgetManager.close`, `.minimize` und `.openWidget` durch Monkey-Patching in ihrer `init()`. Das erzeugt eine 3-stufige Closure-Kette pro Methode. Funktional korrekt, aber fragil und schwer debugbar.
### Lösung
WidgetManager bekommt ein internes Event-System basierend auf `EventTarget`.
**Neue API in `widgets.js`:**
```javascript
_emitter: new EventTarget(),
on(event, handler) {
this._emitter.addEventListener(event, handler);
},
off(event, handler) {
this._emitter.removeEventListener(event, handler);
},
```
**Events:**
| Event | Feuert nach | Detail |
|---|---|---|
| `widget:close` | `entry.el.remove()` + `_widgets.delete(id)` | `{ id }` | **Achtung:** Element bereits entfernt, Listener dürfen nicht auf Widget-Entry zugreifen |
| `widget:minimize` | State-Änderung + Animation + Save | `{ id }` |
| `widget:open` | State-Änderung + Display-Reset + Save | `{ id }` |
**Migration der Widget-Module:**
Das gesamte Monkey-Patching wird ersetzt durch `WidgetManager.on()` Aufrufe:
```javascript
// Beispiel: Calculator.init()
WidgetManager.on('widget:close', (e) => {
if (e.detail.id === self.WIDGET_ID) self.onClose();
});
WidgetManager.on('widget:minimize', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self._isOpen = false;
self.save();
}
});
WidgetManager.on('widget:open', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self._isOpen = true;
const body = WidgetManager.getBody(self.WIDGET_ID);
if (body && body.children.length === 0) self.renderBody(body);
self.save();
}
});
```
ImageRef folgt dem gleichen Pattern, prüft aber per `self._images.some(img => img.id === id)` statt gegen eine feste WIDGET_ID.
**Load-Order:** Kein Problem. `widgets.js` wird vor allen Widget-Modulen geladen. Die Module rufen `WidgetManager.on()` in ihrer `init()` auf, die erst in `app.js` aufgerufen wird.
### Betroffene Dateien
- `src/js/widgets.js` — Event-System hinzufügen, Events in close/minimize/openWidget dispatchen
- `src/js/calculator.js` — Monkey-Patching (Z. 692-728) durch Event-Listener ersetzen
- `src/js/timer.js` — Monkey-Patching (Z. 723-758) durch Event-Listener ersetzen
- `src/js/image-ref.js` — Monkey-Patching (Z. 463-498) durch Event-Listener ersetzen
---
## Sektion 2: Minimize-Animation mit `transitionend`
### Problem
`WidgetManager.minimize()` (`widgets.js:154-163`) setzt `display: none` nach 250ms `setTimeout`. Wenn `openWidget()` in diesen 250ms aufgerufen wird, überschreibt der Timeout das `display: flex` wieder (Race Condition).
### Lösung
`setTimeout` wird durch `transitionend` Event ersetzt. Eine `_minimizing` Flag verhindert die Race Condition.
```javascript
async minimize(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry.state.open = false;
entry._minimizing = true;
entry.el.classList.add('widget-minimized');
entry.el.addEventListener('transitionend', function onEnd(e) {
if (e.target !== entry.el) return;
entry.el.removeEventListener('transitionend', onEnd);
if (entry._minimizing) {
entry.el.style.display = 'none';
}
entry._minimizing = false;
}, { once: false });
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
await this.save();
},
async openWidget(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry._minimizing = false; // Race Condition verhindert
entry.state.open = true;
entry.el.style.display = 'flex';
requestAnimationFrame(() => {
entry.el.classList.remove('widget-minimized');
});
this.bringToFront(id);
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
await this.save();
},
```
**Warum `_minimizing` Flag:** Robuster als `clearTimeout`, weil sie unabhängig von der CSS-Transition-Duration funktioniert.
**Fallback:** Falls `transitionend` nicht feuert (kein Transition definiert), bleibt das Widget sichtbar mit der Klasse. Akzeptabel, da alle Widgets in `main.css` eine Transition haben.
### Betroffene Dateien
- `src/js/widgets.js``minimize()` und `openWidget()` umschreiben
---
## Sektion 3: Security Fixes
### 3a: URL-Injection in backgroundImage
**Datei:** `src/js/settings.js:93`
**Problem:** `settings.bgUrl` wird unvalidiert in CSS-Template-Literal eingefügt.
**Fix:** Protokoll-Whitelist. Nur `blob:` und `data:image/` erlauben (die einzigen Protokolle die der Upload erzeugt).
```javascript
function isValidBgUrl(url) {
return typeof url === 'string' &&
(url.startsWith('blob:') || url.startsWith('data:image/'));
}
```
Validierung an zwei Stellen: `applySettings()` und beim Speichern nach Upload.
### 3b: URL-Validierung beim JSON-Import
**Datei:** `src/js/data.js:45-49`
**Problem:** Importierte Bookmark-URLs werden nicht auf Protokoll geprüft. `javascript:` oder `data:` URLs kommen durch.
**Fix:** Protokoll-Whitelist für importierte URLs.
```javascript
function isSafeUrl(url) {
try {
const u = new URL(url);
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
} catch {
return false;
}
}
```
Integration in die Bookmark-Filter-Logik: `if (!bm || typeof bm.title !== 'string' || !isSafeUrl(bm.url)) return false;`
Ungültige Bookmarks werden still übersprungen.
### 3c: Objekt-Mutation im Import
**Datei:** `src/js/data.js:43-48`
**Problem:** `b.id = b.id || uid()` mutiert das geparste JSON-Objekt direkt. Keine Längenvalidierung.
**Fix:** Immutable Mapping mit expliziter Feldauswahl und String-Längen-Limits.
```javascript
.map(bm => ({
id: bm.id || uid(),
title: String(bm.title).slice(0, 200),
url: bm.url,
desc: String(bm.desc || '').slice(0, 500)
}));
```
Analog für Boards:
```javascript
.map(b => ({
id: b.id || uid(),
title: String(b.title).slice(0, 100),
blurred: !!b.blurred,
bookmarks: /* bereits sanitized, siehe oben */
}));
```
Notes-Felder beim Import werden ebenfalls sanitized:
```javascript
.filter(n => n && n.id && n.template)
.map(n => ({
id: n.id,
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
title: String(n.title || '').slice(0, 200),
content: String(n.content || '').slice(0, 5000),
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
}));
```
### Betroffene Dateien
- `src/js/settings.js``isValidBgUrl()` + Validierung in `applySettings()`
- `src/js/data.js``isSafeUrl()` + immutable Mapping + Längen-Limits
---
## Sektion 4: Lokale Favicons
### Problem
`getFaviconUrl()` (`state.js:36-43`) ruft Google Favicons API auf. Brave Shields blockiert das. Jeder Bookmark erzeugt einen fehlgeschlagenen Netzwerk-Request. Zusätzlich leakt jeder Hostname an Google.
### Lösung
Kein externer Request mehr. `getFaviconUrl()` wird entfernt. Bookmarks zeigen ein farbiges Buchstaben-Icon (erster Buchstabe des Titels).
**state.js:** `getFaviconUrl()` löschen.
**boards.js:** Statt `<img>` + Error-Fallback nur noch ein `<div>`:
```javascript
const favicon = document.createElement('div');
favicon.className = 'bm-favicon-local';
favicon.textContent = bm.title.charAt(0).toUpperCase();
// Deterministische Farbe pro Buchstabe
const hue = (bm.title.charCodeAt(0) * 137) % 360;
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
```
Inline-Style für `backgroundColor` ist hier gerechtfertigt, weil der Wert dynamisch pro Bookmark berechnet wird. Restliche Styles (Größe, Border-Radius, Schrift) kommen aus CSS.
**main.css:** `.bm-favicon` und `.bm-favicon-fallback` ersetzen durch `.bm-favicon-local`.
### Was entfällt
- `getFaviconUrl()` in `state.js`
- `<img class="bm-favicon">` Erzeugung in `boards.js`
- Error-Listener für Favicon-Loads
- `.bm-favicon` und `.bm-favicon-fallback` CSS-Regeln
- Der einzige externe Netzwerk-Request der Extension
### Betroffene Dateien
- `src/js/state.js``getFaviconUrl()` entfernen
- `src/js/boards.js` — Favicon-Rendering umbauen
- `src/css/main.css` — CSS-Klassen tauschen
---
## Sektion 5: i18n-Lücken schließen
### 5a: Toolbar-Buttons — fehlende `data-i18n-title`
Fünf Header-Buttons (`newtab.html:26-42`) haben hardcodierte deutsche `title`-Attribute.
| Button | Key | DE | EN |
|---|---|---|---|
| `#btnImport` | `header.import_title` | Bookmarks importieren (HTML) | Import bookmarks (HTML) |
| `#btnAddBoard` | `header.board_title` | Neues Board hinzufügen | Add new board |
| `#btnNote` | `header.note_title` | Schnellnotiz | Quick note |
| `#btnTheme` | `header.theme_title` | Darstellung & Theme | Appearance & Theme |
| `#btnSettings` | `header.settings_title` | Einstellungen | Settings |
**Fix:** `data-i18n-title` Attribute hinzufügen. `applyLanguage()` erkennt diese automatisch.
### 5b: Button-Texte ohne i18n
Drei Settings-Buttons haben hardcodierte Texte.
| Button | Key | DE | EN |
|---|---|---|---|
| `#btnRestartOnboarding` | `settings.onboarding_btn` | Start | Start |
| `#btnResetAll` | `settings.reset_btn` | Reset | Reset |
| `#btnBgFile` | `settings.bg_upload_btn` | Upload | Upload |
Aktuell in beiden Sprachen identisch, aber `data-i18n` wird für Konsistenz und zukünftige Erweiterbarkeit gesetzt.
### Betroffene Dateien
- `newtab.html` — 5x `data-i18n-title`, 3x `data-i18n` hinzufügen
- `src/js/i18n.js` — 8 neue Keys in `STRINGS.de` und `STRINGS.en`
---
## Sektion 6: Code-Qualität
### 6a: Notes-Mutation beim Import
**Datei:** `src/js/data.js:~79`
**Problem:** `Notes._notes = merged` setzt das interne Array direkt, umgeht `Notes.save()`.
**Fix:** Nach dem Speichern in `widgetStates` wird `Notes.init()` aufgerufen statt das interne Array direkt zu manipulieren.
```javascript
existingWidgets.notes = merged;
await Store.set('widgetStates', existingWidgets);
await Notes.init(); // Neu aus Storage laden + UI rendern
```
### 6b: `backdrop-filter` Fallback
**Datei:** `src/css/main.css`
**Problem:** 24 Stellen mit `backdrop-filter`. Brave Shields kann das blockieren.
**Fix:** Zentraler `@supports not` Block mit solidem Hintergrund-Fallback:
```css
@supports not (backdrop-filter: blur(1px)) {
.board,
.widget,
.settings-panel,
.dialog-box,
.theme-modal {
background-color: var(--bg-solid-fallback);
}
}
```
Jedes Theme bekommt `--bg-solid-fallback` als deckende Variante der Glassmorphism-Farbe.
### 6c: Clock Interval Cleanup
**Datei:** `src/js/app.js:135`
**Problem:** `setInterval(tick, 1000)` ID wird nicht gespeichert.
**Fix:** Interval-ID in Variable speichern. Niedrigste Priorität, da der Interval mit dem Tab stirbt.
```javascript
let _clockInterval = null;
_clockInterval = setInterval(tick, 1000);
```
### Betroffene Dateien
- `src/js/data.js` — Notes-Import über `Notes.init()` statt direkter Mutation
- `src/css/main.css``@supports not` Block + `--bg-solid-fallback` pro Theme
- `src/js/app.js` — Interval-ID speichern
---
## Implementierungsreihenfolge (Foundation First)
1. **Event-System** in `widgets.js` bauen
2. **Widget-Module** auf Events migrieren (`calculator.js`, `timer.js`, `image-ref.js`)
3. **Minimize mit `transitionend`** in `widgets.js`
4. **Security Fixes** in `settings.js` und `data.js`
5. **Lokale Favicons** in `state.js`, `boards.js`, `main.css`
6. **i18n-Lücken** in `newtab.html` und `i18n.js`
7. **Code-Qualität** in `data.js`, `main.css`, `app.js`
8. **Version Bump** auf 2.0.1 in allen drei Manifests + CHANGELOG
## Betroffene Dateien (Gesamt)
| Datei | Sektionen |
|---|---|
| `src/js/widgets.js` | 1, 2 |
| `src/js/calculator.js` | 1 |
| `src/js/timer.js` | 1 |
| `src/js/image-ref.js` | 1 |
| `src/js/settings.js` | 3a |
| `src/js/data.js` | 3b, 3c, 6a |
| `src/js/state.js` | 4 |
| `src/js/boards.js` | 4 |
| `src/js/i18n.js` | 5 |
| `src/js/app.js` | 6c |
| `src/css/main.css` | 4, 6b |
| `newtab.html` | 5 |
| `manifest.json` | 8 |
| `manifest.firefox.json` | 8 |
| `manifest.opera.json` | 8 |
| `CHANGELOG.md` | 8 |
+327
View File
@@ -0,0 +1,327 @@
# Hellion Dashboard — Widget Schema
> This document is intentionally written in English. Full German/English i18n support
> is planned for v2.0 — until then, English keeps the docs accessible to anyone
> who wants to contribute or fork the project.
---
## Overview
The widget system provides draggable, resizable floating panels managed by `WidgetManager` (`src/js/widgets.js`). Each widget type has its own module that handles content rendering and state management — `WidgetManager` only knows about DOM and position, never about content.
---
## Widget Types
| Type | Module | Instance | Max | Storage |
|---|---|---|---|---|
| `note` | `notes.js` | Multi | 5 | Persistent (`widgetStates.notes`) |
| `calculator` | `calculator.js` | Single | 1 | Persistent (`widgetStates.calculator`) |
| `timer` | `timer.js` | Single | 1 | Persistent (`widgetStates.timer`) |
| `image` | `image-ref.js` | Multi | 3 | Meta: persistent, Image data: sessionStorage |
---
## WidgetManager API
### `create(type, config) → string`
Creates a widget and appends it to the DOM. Returns the widget ID.
```javascript
const id = WidgetManager.create('note', {
id: 'note_abc123', // Optional, auto-generated if omitted
title: 'My Note', // Default: 'Note'
x: 120, // Left position in px
y: 80, // Top position in px
width: 280, // Width in px (min: 200)
height: 220, // Height in px (min: 150)
open: true // Visible state (default: true)
});
```
### `getBody(id) → HTMLElement | null`
Returns the `.widget-body` element. This is where your module renders its content.
```javascript
const body = WidgetManager.getBody('widget_calculator');
if (body) Calculator.renderBody(body);
```
### `getState(id) → Object | null`
Returns the current widget state — position, size, open status.
```javascript
const state = WidgetManager.getState('widget_timer');
// → { id, type, title, x, y, width, height, open }
```
### `close(id)`
Permanently removes a widget from the DOM and registry. No undo.
### `minimize(id)`
Hides a widget with animation. The widget stays in the registry with `open: false` so it can be restored.
### `openWidget(id)`
Restores a minimized widget with animation.
### `bringToFront(id)`
Increments z-index so the widget sits above everything else. Called automatically on `pointerdown`.
### `save() → Array`
Returns an array of all `type: 'note'` widget states. Used by `Notes.save()` to merge position/size data with note content.
### `restore(renderCallback)`
Loads widget states from storage and recreates all note widgets. Single-instance widgets (Calculator, Timer) restore themselves in their own `init()``restore()` only handles notes.
---
## Shared Storage Key: `widgetStates`
All widget modules share a single storage key. Every module's `save()` must read first and preserve whatever it doesn't own — otherwise modules silently wipe each other's data on every save.
```javascript
// Full widgetStates structure
{
notes: [
{
id: 'note_abc123',
title: 'My Note',
content: 'Hello world',
template: 'text', // 'text' or 'checklist'
x: 120, y: 80,
width: 280, height: 220,
open: true,
checklistItems: [], // Only used by checklist template
checkedItems: [] // Checked item IDs
}
],
calculator: {
x: 400, y: 120,
width: 280, height: 400,
open: false,
history: [
{ expr: '2 + 3', result: '5' }
]
},
timer: {
x: 600, y: 80,
width: 260, height: 360,
open: false,
muted: false,
presets: [
{ name: 'Forschung', seconds: 2700 }
]
},
imageRef: {
images: [
{
id: 'image_0',
label: 'Bauplan',
x: 200, y: 120,
width: 320, height: 280,
open: true
// Image data is NOT stored here — sessionStorage only
}
]
}
}
```
### The Save Pattern
Every module that touches `widgetStates` must follow this pattern:
```javascript
// From notes.js — same pattern applies to every widget module
async save() {
const existing = await Store.get(this.STORAGE_KEY);
const saveData = { notes: mergedNotes };
// Preserve everything we don't own
if (existing && existing.calculator) saveData.calculator = existing.calculator;
if (existing && existing.timer) saveData.timer = existing.timer;
if (existing && existing.imageRef) saveData.imageRef = existing.imageRef;
await Store.set(this.STORAGE_KEY, saveData);
}
```
---
## Creating a New Widget Type
### Step 1: Single or Multi-Instance?
**Single-instance** (Calculator, Timer style): one widget, fixed ID, `toggle()` opens and closes it.
**Multi-instance** (Notes, ImageRef style): multiple widgets, dynamic IDs, `create()` adds new ones.
### Step 2: Create the Module
Here's a minimal single-instance widget template. Follow the same structure — the lifecycle hooks especially are easy to get wrong.
```javascript
const YourWidget = {
WIDGET_ID: 'widget_yourwidget',
STORAGE_KEY: 'widgetStates',
_isOpen: false,
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.yourWidget) {
// Restore your state
}
},
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
if (data.notes === undefined) data.notes = [];
const widgetState = WidgetManager.getState(this.WIDGET_ID);
data.yourWidget = {
x: widgetState ? widgetState.x : 400,
y: widgetState ? widgetState.y : 120,
width: widgetState ? widgetState.width : 280,
height: widgetState ? widgetState.height : 300,
open: this._isOpen,
// ... your custom data
};
await Store.set(this.STORAGE_KEY, data);
},
async open() {
if (this._isOpen) {
WidgetManager.bringToFront(this.WIDGET_ID);
return;
}
const data = await Store.get(this.STORAGE_KEY);
const saved = (data && data.yourWidget) ? data.yourWidget : {};
WidgetManager.create('yourwidget', {
id: this.WIDGET_ID,
title: 'Your Widget',
x: saved.x || 400,
y: saved.y || 120,
width: saved.width || 280,
height: saved.height || 300,
open: true
});
const body = WidgetManager.getBody(this.WIDGET_ID);
if (body) this.renderBody(body);
this._isOpen = true;
await this.save();
},
async toggle() {
if (this._isOpen) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry && entry.state.open) {
await WidgetManager.minimize(this.WIDGET_ID);
this._isOpen = false;
await this.save();
} else if (entry) {
await WidgetManager.openWidget(this.WIDGET_ID);
this._isOpen = true;
await this.save();
}
} else {
await this.open();
}
},
renderBody(bodyEl) {
bodyEl.textContent = '';
// Build your UI with createElement — never innerHTML!
},
async init() {
await this.load();
const data = await Store.get(this.STORAGE_KEY);
if (data && data.yourWidget && data.yourWidget.open) {
await this.open();
}
// Lifecycle hooks — always call the previous method first
// or you'll break every widget that wrapped before yours
const self = this;
const prevClose = WidgetManager.close;
WidgetManager.close = function(id) {
prevClose.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = false;
self.save();
}
};
const prevMinimize = WidgetManager.minimize;
WidgetManager.minimize = async function(id) {
await prevMinimize.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = false;
await self.save();
}
};
const prevOpen = WidgetManager.openWidget;
WidgetManager.openWidget = async function(id) {
await prevOpen.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = true;
const body = WidgetManager.getBody(self.WIDGET_ID);
if (body && body.children.length === 0) {
self.renderBody(body);
}
await self.save();
}
};
}
};
```
### Step 3: Integration Checklist
1. `newtab.html` — Add `<script>` tag after `widgets.js` and before `data.js`
2. `newtab.html` — Add toolbar button: `<button class="widget-toolbar-btn" data-action="your-action">`
3. `notes.js` — Add handler in `initToolbar()`: `else if (action === 'your-action') { YourWidget.toggle(); }`
4. `notes.js` — Preserve your key in `save()`: `if (existing && existing.yourWidget) saveData.yourWidget = existing.yourWidget;`
5. `app.js` — Add `await YourWidget.init();` to the init sequence
6. `main.css` — Add widget-specific styles
7. `data.js` — Add export/import logic if your data should survive a JSON backup
---
## Widget DOM Structure
Every widget created by `WidgetManager.create()` has this structure. Your module renders into `.widget-body` via `renderBody()` — never touch the header or resize handle.
```html
<div class="widget" data-widget-id="widget_abc123"
style="left: 120px; top: 80px; width: 280px; height: 220px;">
<div class="widget-header"> <!-- Drag handle -->
<span class="widget-title">Title</span> <!-- Double-click to edit, max 20 chars -->
<div class="widget-actions">
<button class="widget-btn widget-minimize"></button>
<button class="widget-btn widget-close"></button>
</div>
</div>
<div class="widget-body">
<!-- Your content goes here via renderBody() -->
</div>
<div class="widget-resize-handle"></div> <!-- Bottom-right, visible on hover -->
</div>
```
+28 -4
View File
@@ -1,8 +1,9 @@
{
"manifest_version": 3,
"name": "Hellion NewTab",
"version": "1.5.2",
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
"name": "__MSG_extName__",
"default_locale": "en",
"version": "2.3.0",
"description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de",
@@ -10,14 +11,34 @@
"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": [
"storage",
"bookmarks"
"bookmarks",
"activeTab"
],
"browser_specific_settings": {
"gecko": {
"id": "hellion-newtab@hellion-media.de",
"update_url": "https://hellion-media.de/extensions/firefox/updates.json",
"strict_min_version": "142.0",
"data_collection_permissions": {
"required": [
@@ -32,6 +53,9 @@
"matches": ["<all_urls>"]
}
],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"icons": {
"16": "assets/icons/icon16.png",
"48": "assets/icons/icon48.png",
+24 -4
View File
@@ -1,16 +1,33 @@
{
"manifest_version": 3,
"name": "Hellion NewTab",
"version": "1.5.2",
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
"name": "__MSG_extName__",
"default_locale": "en",
"version": "2.3.0",
"description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de",
"chrome_url_overrides": {
"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": [
"storage",
"bookmarks"
"bookmarks",
"activeTab"
],
"web_accessible_resources": [
{
@@ -18,6 +35,9 @@
"matches": ["<all_urls>"]
}
],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"icons": {
"16": "assets/icons/icon16.png",
"48": "assets/icons/icon48.png",
+17 -3
View File
@@ -1,8 +1,9 @@
{
"manifest_version": 3,
"name": "Hellion Dashboard (GX Native)",
"version": "1.5.2",
"description": "Ersetzt die Opera GX Startseite durch dein persönliches, leistungsoptimiertes Hellion Dashboard. Schnell, sauber und werbefrei.",
"name": "__MSG_extName__",
"default_locale": "en",
"version": "2.3.0",
"description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de",
@@ -39,6 +40,19 @@
"default_title": "Hellion Dashboard"
},
"commands": {
"quick-save": {
"suggested_key": {
"default": "Alt+Shift+S",
"mac": "Alt+Shift+S"
},
"description": "__MSG_cmdQuickSave__"
}
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"icons": {
"16": "assets/icons/icon16.png",
"48": "assets/icons/icon48.png",
+300 -198
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -23,25 +23,29 @@
</div>
</div>
<div class="header-right">
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)">
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)" data-i18n-title="header.import_title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import
<span data-i18n="header.import">Import</span>
</button>
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen">
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen" data-i18n-title="header.board_title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Board
<span data-i18n="header.board">Board</span>
</button>
<button class="btn-icon" id="btnNote" title="Schnellnotiz">
<button class="btn-icon" id="btnNote" title="Schnellnotiz" data-i18n-title="header.note_title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Note
<span data-i18n="header.note">Note</span>
</button>
<button class="btn-icon" id="btnTheme" title="Theme wählen">
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme" data-i18n-title="header.theme_title">
<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>
Theme
<span data-i18n="header.theme">Darstellung</span>
</button>
<button class="btn-icon" id="btnSettings" title="Einstellungen">
<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">
<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>
Settings
<span data-i18n="header.settings">Settings</span>
</button>
</div>
</header>
@@ -49,28 +53,50 @@
<!-- SEARCH BAR -->
<div class="search-bar-wrapper" id="searchBarWrapper">
<div class="search-bar">
<button class="search-engine-toggle" id="searchEngineToggle" title="Suchmaschine wechseln">
<button class="search-engine-toggle" id="searchEngineToggle" data-i18n-title="settings.search_engine_toggle" title="Suchmaschine wechseln">
<span id="searchEngineIcon">G</span>
</button>
<input type="text" class="search-input" id="searchInput" placeholder="Search the web…" autocomplete="off" />
<button class="search-submit" id="searchSubmit">
<input type="text" class="search-input" id="searchInput" data-i18n-placeholder="search.placeholder" placeholder="Search the web…" autocomplete="off" />
<button class="search-submit" id="searchSubmit" data-i18n-title="search.submit_title">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
</div>
</div>
<!-- STICKY NOTE -->
<div class="sticky-note" id="stickyNote">
<div class="sticky-note-header" id="stickyNoteHeader">
<span class="sticky-note-title">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Note
</span>
<button class="sticky-note-close" id="stickyNoteClose"></button>
</div>
<textarea class="sticky-note-body" id="stickyNoteBody" placeholder="Quick note…" spellcheck="false"></textarea>
<!-- WIDGET TOOLBAR -->
<div class="widget-toolbar" id="widgetToolbar" role="toolbar" aria-orientation="vertical" data-i18n-aria-label="toolbar.label">
<button class="widget-toolbar-btn" data-action="new-note" data-i18n-title="toolbar.note" title="Note erstellen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
</button>
<button class="widget-toolbar-btn" data-action="new-checklist" data-i18n-title="toolbar.checklist" title="Checkliste erstellen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
</button>
<button class="widget-toolbar-btn" data-action="calculator" data-i18n-title="toolbar.calculator" title="Taschenrechner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><circle cx="8" cy="10" r="0.5"/><circle cx="12" cy="10" r="0.5"/><circle cx="16" cy="10" r="0.5"/><circle cx="8" cy="14" r="0.5"/><circle cx="12" cy="14" r="0.5"/><circle cx="16" cy="14" r="0.5"/><circle cx="8" cy="18" r="0.5"/><circle cx="12" cy="18" r="0.5"/><circle cx="16" cy="18" r="0.5"/></svg>
</button>
<button class="widget-toolbar-btn" data-action="timer" data-i18n-title="toolbar.timer" title="Timer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3l2 2"/><path d="M19 3l-2 2"/><line x1="12" y1="1" x2="12" y2="3"/></svg>
</button>
<button class="widget-toolbar-btn hidden" data-action="image-ref" data-i18n-title="toolbar.imageref" title="Bild-Referenz">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
</button>
<button class="widget-toolbar-btn" data-action="notebook" data-i18n-title="toolbar.notebook" title="Alle Notes">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
</button>
</div>
<!-- NOTEBOOK SIDEBAR -->
<div class="notebook-overlay" id="notebookOverlay"></div>
<aside class="notebook-panel" id="notebookPanel">
<div class="notebook-header">
<span class="notebook-header-title"><span data-i18n="notebook.title">Notebook</span> <span class="notebook-count" id="notebookCount">0 / 5</span></span>
<button class="btn-close" id="btnCloseNotebook" data-i18n-title="dialog.close"></button>
</div>
<div class="notebook-slots" id="notebookSlots">
<!-- dynamisch via JS -->
</div>
</aside>
<!-- BOARDS CONTAINER -->
<main class="boards-wrapper" id="boardsWrapper">
<!-- dynamisch via JS -->
@@ -81,191 +107,113 @@
<!-- SETTINGS PANEL -->
<div class="panel-overlay" id="settingsOverlay"></div>
<aside class="settings-panel" id="settingsPanel">
<aside class="settings-panel" id="settingsPanel" role="dialog" aria-modal="true" aria-labelledby="settingsPanelTitle" aria-hidden="true">
<div class="panel-header">
<span>Settings</span>
<button class="btn-close" id="btnCloseSettings"></button>
<span id="settingsPanelTitle" data-i18n="settings.title">Einstellungen</span>
<button class="btn-close" id="btnCloseSettings" data-i18n-title="dialog.close"></button>
</div>
<div class="panel-body">
<!-- APPEARANCE -->
<section class="settings-section" data-section="appearance">
<!-- SPRACHE -->
<section class="settings-section" data-section="language">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
APPEARANCE
<span data-i18n="settings.section.display">DARSTELLUNG</span>
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Compact mode</span>
<span class="setting-desc">Reduce spacing to show more bookmarks</span>
<span class="setting-label" data-i18n="settings.language">Sprache</span>
<span class="setting-desc" data-i18n="settings.language.desc">Anzeigesprache wählen</span>
</div>
<label class="toggle"><input type="checkbox" id="settingCompact" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Shorten long titles</span>
<span class="setting-desc">Shorten title to one line with "…"</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShorten" /><span class="slider"></span></label>
<select class="select-input" id="settingLanguage">
<option value="auto" data-i18n="settings.language.auto">Automatisch</option>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
</section>
<!-- BEHAVIOR -->
<section class="settings-section" data-section="behavior">
<!-- WIDGETS -->
<section class="settings-section" data-section="widgets">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
BEHAVIOR
<span data-i18n="settings.section.widgets">WIDGETS</span>
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Open links in new tab</span>
<span class="setting-desc">Open bookmarks in a new browser tab</span>
<span class="setting-label" data-i18n="settings.toolbar_pos">Toolbar-Position</span>
<span class="setting-desc" data-i18n="settings.toolbar_pos.desc">Widget-Toolbar links oder rechts anzeigen</span>
</div>
<label class="toggle"><input type="checkbox" id="settingNewTab" checked /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Show bookmark descriptions</span>
<span class="setting-desc">Display saved descriptions below bookmark titles</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShowDesc" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Hide extra bookmarks in long boards</span>
<span class="setting-desc">Automatically hides extra bookmarks in long boards</span>
</div>
<label class="toggle"><input type="checkbox" id="settingHideExtra" /><span class="slider"></span></label>
</div>
<div class="setting-row" id="visibleCountRow">
<div class="setting-info">
<span class="setting-label">Visible bookmarks before hide</span>
<span class="setting-desc">Choose how many bookmarks are shown</span>
</div>
<select class="select-input" id="settingVisibleCount">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
<select class="select-input" id="settingToolbarPos">
<option value="right" selected data-i18n="settings.toolbar_pos.right">Rechts</option>
<option value="left" data-i18n="settings.toolbar_pos.left">Links</option>
</select>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Suchleiste anzeigen</span>
<span class="setting-desc">Suchleiste unter dem Header ein/aus</span>
<span class="setting-label" data-i18n="settings.image_ref">Bild-Referenz Widgets</span>
<span class="setting-desc" data-i18n="settings.image_ref.desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShowSearch" checked /><span class="slider"></span></label>
<label class="toggle">
<input type="checkbox" id="settingImageRef" role="switch" aria-checked="false">
<span class="slider"></span>
</label>
</div>
</div>
</section>
<!-- DATA -->
<!-- DATEN & HILFE -->
<section class="settings-section" data-section="data">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
DATA
<span data-i18n="settings.section.data">DATEN &amp; HILFE</span>
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Export Boards</span>
<span class="setting-desc">Alle Boards als JSON sichern</span>
<span class="setting-label" data-i18n="settings.export">Backup exportieren</span>
<span class="setting-desc" data-i18n="settings.export.desc">Alle Boards, Notes und Einstellungen als JSON sichern</span>
</div>
<button class="btn-small" id="btnExportJSON">Export</button>
<button class="btn-small" id="btnExportJSON" data-i18n="settings.export.btn">Export</button>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Import Boards</span>
<span class="setting-desc">JSON-Backup wiederherstellen</span>
<span class="setting-label" data-i18n="settings.import">Backup importieren</span>
<span class="setting-desc" data-i18n="settings.import.desc">JSON-Backup wiederherstellen</span>
</div>
<button class="btn-small" id="btnImportJSON">Import</button>
<button class="btn-small" id="btnImportJSON" data-i18n="header.import">Import</button>
<input type="file" id="jsonImportInput" accept=".json" class="hidden" />
</div>
</div>
</section>
<!-- HELP -->
<section class="settings-section" data-section="help">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
HELP
</button>
<div class="section-content">
<div class="setting-row" id="browserImportRow">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.browser_import">Browser-Lesezeichen</span>
<span class="setting-desc" data-i18n="settings.browser_import.desc">Lesezeichen direkt aus dem Browser importieren</span>
</div>
<button class="btn-small" id="btnBrowserImport" data-i18n="header.import">Import</button>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Onboarding wiederholen</span>
<span class="setting-desc">Willkommens-Tour erneut anzeigen</span>
<span class="setting-label" data-i18n="settings.onboarding">Onboarding wiederholen</span>
<span class="setting-desc" data-i18n="settings.onboarding.desc">Willkommens-Tour erneut anzeigen</span>
</div>
<button class="btn-small" id="btnRestartOnboarding">Start</button>
<button class="btn-small" id="btnRestartOnboarding" data-i18n="settings.onboarding_btn">Start</button>
</div>
</div>
</section>
<!-- ABOUT / IMPRESSUM -->
<section class="settings-section" data-section="about">
<!-- PAPIERKORB / TRASH -->
<section class="settings-section" data-section="trash">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
ABOUT
<span data-i18n="trash.section">PAPIERKORB</span>
</button>
<div class="section-content">
<div class="about-block">
<div class="about-logo">⬡ HELLION NEWTAB</div>
<div class="about-version">Version 1.5.2 · by Hellion Online Media</div>
<div class="about-links">
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Impressum
</a>
<a href="https://hellion-media.de" target="_blank" class="about-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
hellion-media.de
</a>
</div>
<div class="about-divider"></div>
<div class="about-info-row">
<span class="about-info-label">Entwickler</span>
<span class="about-info-value">Florian Wathling</span>
</div>
<div class="about-info-row">
<span class="about-info-label">Unternehmen</span>
<span class="about-info-value">Hellion Online Media</span>
</div>
<div class="about-info-row">
<span class="about-info-label">Lizenz</span>
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" class="about-info-value about-link-subtle">CC BY-NC-SA 4.0</a>
</div>
<div class="about-info-row">
<span class="about-info-label">Datenspeicherung</span>
<span class="about-info-value">100% lokal · Kein Server · Kein Account</span>
</div>
<div class="about-divider"></div>
<div class="about-bugreport">
<span class="about-info-label about-info-label-block">Bug Report / Feedback</span>
<a href="mailto:kontakt@hellion-media.de?subject=Hellion NewTab Bug Report" class="about-link-mail">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
kontakt@hellion-media.de
</a>
</div>
<div class="about-browsers">
<span class="about-info-label about-info-label-block">Kompatible Browser</span>
<div class="about-browser-tags">
<span class="browser-tag">Chrome</span>
<span class="browser-tag">Edge</span>
<span class="browser-tag">Firefox</span>
<span class="browser-tag">Opera</span>
<span class="browser-tag">Opera GX</span>
<span class="browser-tag">Brave</span>
<span class="browser-tag">Vivaldi</span>
</div>
</div>
<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>
@@ -274,93 +222,233 @@
<section class="settings-section" data-section="danger">
<button class="settings-section-title danger" type="button">
<span class="section-chevron"></span>
DANGER ZONE
<span data-i18n="settings.section.danger">DANGER ZONE</span>
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Reset all data</span>
<span class="setting-desc">Deletes all boards and bookmarks</span>
<span class="setting-label" data-i18n="settings.reset">Alles zurücksetzen</span>
<span class="setting-desc" data-i18n="settings.reset.desc">Löscht alle Boards, Notes und Einstellungen</span>
</div>
<button class="btn-danger" id="btnResetAll">Reset</button>
<button class="btn-danger" id="btnResetAll" data-i18n="settings.reset_btn">Reset</button>
</div>
</div>
</section>
</div>
<!-- ABOUT — fixiert am unteren Rand -->
<div class="panel-footer">
<div class="about-block">
<div class="about-logo" data-i18n="about.title">⬡ HELLION NEWTAB</div>
<div class="about-version">Version 2.3.0 · by Hellion Online Media</div>
<div class="about-links">
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span data-i18n="about.impressum">Impressum</span>
</a>
<a href="https://hellion-media.de" target="_blank" class="about-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
hellion-media.de
</a>
</div>
<div class="about-divider"></div>
<div class="about-info-row">
<span class="about-info-label" data-i18n="about.developer">Entwickler</span>
<span class="about-info-value">Florian Wathling</span>
</div>
<div class="about-info-row">
<span class="about-info-label" data-i18n="about.company">Unternehmen</span>
<span class="about-info-value">Hellion Online Media</span>
</div>
<div class="about-info-row">
<span class="about-info-label" data-i18n="about.license">Lizenz</span>
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" class="about-info-value about-link-subtle">CC BY-NC-SA 4.0</a>
</div>
<div class="about-info-row">
<span class="about-info-label" data-i18n="about.storage">Datenspeicherung</span>
<span class="about-info-value" data-i18n="about.storage.value">100% lokal · Kein Server · Kein Account</span>
</div>
<div class="about-divider"></div>
<div class="about-bugreport">
<span class="about-info-label about-info-label-block" data-i18n="about.bugreport">Bug Report / Feedback</span>
<a href="mailto:kontakt@hellion-media.de?subject=Hellion NewTab Bug Report" class="about-link-mail">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
kontakt@hellion-media.de
</a>
</div>
<div class="about-bugreport">
<span class="about-info-label about-info-label-block" data-i18n="about.support">Support</span>
<a href="https://ko-fi.com/hellionmedia" target="_blank" class="about-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 010 8h-1"/><path d="M2 8h16v9a4 4 0 01-4 4H6a4 4 0 01-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>
Ko-fi — hellionmedia
</a>
</div>
<div class="about-browsers">
<span class="about-info-label about-info-label-block" data-i18n="about.browsers">Kompatible Browser</span>
<div class="about-browser-tags">
<span class="browser-tag">Chrome</span>
<span class="browser-tag">Edge</span>
<span class="browser-tag">Firefox</span>
<span class="browser-tag">Opera</span>
<span class="browser-tag">Opera GX</span>
<span class="browser-tag">Brave</span>
<span class="browser-tag">Vivaldi</span>
</div>
</div>
</div>
</div>
</aside>
<!-- THEME PICKER MODAL -->
<div class="modal-overlay" id="themeOverlay">
<div class="theme-modal" id="themeModal">
<div class="theme-modal" id="themeModal" role="dialog" aria-modal="true" aria-labelledby="themeModalTitle" aria-hidden="true">
<div class="modal-header">
<span>Theme wählen</span>
<button class="btn-close" id="btnCloseTheme"></button>
<span id="themeModalTitle" data-i18n="modal.theme_header">Darstellung</span>
<button class="btn-close" id="btnCloseTheme" data-i18n-title="dialog.close"></button>
</div>
<div class="theme-grid">
<div class="theme-card active" data-value="nebula">
<img class="theme-card-img" src="assets/themes/bg-nebula.jpg" alt="Nebula" />
<div class="theme-card active" data-value="nebula" role="button" tabindex="0" aria-pressed="true" data-i18n-aria-label="theme.card.nebula">
<img class="theme-card-img" src="assets/themes/bg-nebula.webp" alt="Nebula" />
<span class="theme-card-label">Nebula</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="crescent">
<img class="theme-card-img" src="assets/themes/bg-crescent.jpg" alt="Crescent" />
<div class="theme-card" data-value="crescent" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.crescent">
<img class="theme-card-img" src="assets/themes/bg-crescent.webp" alt="Crescent" />
<span class="theme-card-label">Crescent</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="event-horizon">
<img class="theme-card-img" src="assets/themes/bg-event-horizon.jpg" alt="Event Horizon" />
<div class="theme-card" data-value="event-horizon" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.event_horizon">
<img class="theme-card-img" src="assets/themes/bg-event-horizon.webp" alt="Event Horizon" />
<span class="theme-card-label">Event Horizon</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="merchantman">
<div class="theme-card" data-value="merchantman" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.merchantman">
<img class="theme-card-img" src="assets/themes/bg-merchantman.webp" alt="Merchantman" />
<span class="theme-card-label">Merchantman</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="julia-jin">
<img class="theme-card-img" src="assets/themes/bg-julia-jin.png" alt="Julia &amp; Jin" />
<div class="theme-card" data-value="julia-jin" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.julia_jin">
<img class="theme-card-img" src="assets/themes/bg-julia-jin.webp" alt="Julia &amp; Jin" />
<span class="theme-card-label">Julia &amp; Jin</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="sc-sunset">
<img class="theme-card-img" src="assets/themes/bg-sc-sunset.jpg" alt="SC Sunset" />
<div class="theme-card" data-value="sc-sunset" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.sc_sunset">
<img class="theme-card-img" src="assets/themes/bg-sc-sunset.webp" alt="SC Sunset" />
<span class="theme-card-label">SC Sunset</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="hellion-hud">
<img class="theme-card-img" src="assets/themes/bg-hellion-hud.png" alt="Hellion HUD" />
<div class="theme-card" data-value="hellion-hud" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.hellion_hud">
<img class="theme-card-img" src="assets/themes/bg-hellion-hud.webp" alt="Hellion HUD" />
<span class="theme-card-label">HUD</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="hellion-energy">
<img class="theme-card-img" src="assets/themes/bg-hellion-energy.jpg" alt="Hellion Energy" />
<div class="theme-card" data-value="hellion-energy" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.hellion_energy">
<img class="theme-card-img" src="assets/themes/bg-hellion-energy.webp" alt="Hellion Energy" />
<span class="theme-card-label">Energy</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="satisfactory" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.satisfactory">
<img class="theme-card-img" src="assets/themes/bg-satisfactory.webp" alt="Satisfactory" />
<span class="theme-card-label">Satisfactory</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="avorion" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.avorion">
<img class="theme-card-img" src="assets/themes/bg-avorion.webp" alt="Avorion" />
<span class="theme-card-label">Avorion</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="hellion-stealth" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.hellion_stealth">
<img class="theme-card-img" src="assets/themes/bg-scPolaris.webp" alt="Hellion Stealth" />
<span class="theme-card-label">Stealth</span>
<span class="theme-card-check"></span>
</div>
</div>
<div class="theme-modal-section">
<h3 class="settings-section-title">BACKGROUND</h3>
<h3 class="settings-section-title" data-i18n="settings.section.bg">HINTERGRUND</h3>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Background image URL</span>
<span class="setting-desc">Custom wallpaper URL</span>
<span class="setting-label" data-i18n="settings.bg_url">Bild-URL</span>
<span class="setting-desc" data-i18n="settings.bg_url.desc">Eigenes Hintergrundbild per URL</span>
</div>
<button class="btn-small" id="btnChangeBg">Change</button>
<button class="btn-small" id="btnChangeBg" data-i18n="settings.bg_change">Ändern</button>
</div>
<div class="setting-row hidden" id="bgInputRow">
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... or leave empty for default" />
<button class="btn-small" id="btnApplyBg">Apply</button>
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... oder leer für Standard" />
<button class="btn-small" id="btnApplyBg" data-i18n="settings.bg_apply">Übernehmen</button>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Background file upload</span>
<span class="setting-desc">Use a local image as background</span>
<span class="setting-label" data-i18n="settings.bg_upload">Datei hochladen</span>
<span class="setting-desc" data-i18n="settings.bg_upload.desc">Lokales Bild als Hintergrund verwenden</span>
</div>
<button class="btn-small" id="btnBgFile">Upload</button>
<button class="btn-small" id="btnBgFile" data-i18n="settings.bg_upload_btn">Upload</button>
<input type="file" id="bgFileInput" accept="image/*" class="hidden" />
</div>
</div>
<div class="theme-modal-section">
<h3 class="settings-section-title" data-i18n="settings.section.display">DARSTELLUNG</h3>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.compact">Kompaktmodus</span>
<span class="setting-desc" data-i18n="settings.compact.desc">Weniger Abstand für mehr Bookmarks</span>
</div>
<label class="toggle"><input type="checkbox" id="settingCompact" role="switch" aria-checked="false" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.shorten">Lange Titel kürzen</span>
<span class="setting-desc" data-i18n="settings.shorten.desc">Titel auf eine Zeile mit „…" kürzen</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShorten" role="switch" aria-checked="false" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.search">Suchleiste anzeigen</span>
<span class="setting-desc" data-i18n="settings.search.desc">Suchleiste unter dem Header ein/aus</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShowSearch" role="switch" aria-checked="true" checked /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.newtab">Links in neuem Tab</span>
<span class="setting-desc" data-i18n="settings.newtab.desc">Bookmarks in neuem Browser-Tab öffnen</span>
</div>
<label class="toggle"><input type="checkbox" id="settingNewTab" role="switch" aria-checked="true" checked /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.showdesc">Beschreibungen anzeigen</span>
<span class="setting-desc" data-i18n="settings.showdesc.desc">Gespeicherte Beschreibung unter Bookmarks</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShowDesc" role="switch" aria-checked="false" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.hideextra">Bookmarks ausblenden</span>
<span class="setting-desc" data-i18n="settings.hideextra.desc">Überzählige Bookmarks in langen Boards verstecken</span>
</div>
<label class="toggle"><input type="checkbox" id="settingHideExtra" role="switch" aria-checked="false" /><span class="slider"></span></label>
</div>
<div class="setting-row" id="visibleCountRow">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.visible_count">Sichtbare Bookmarks</span>
<span class="setting-desc" data-i18n="settings.visible_count.desc">Anzahl vor dem Ausblenden</span>
</div>
<select class="select-input" id="settingVisibleCount">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
</select>
</div>
</div>
</div>
</div>
@@ -368,14 +456,14 @@
<div class="modal-overlay" id="addBoardOverlay">
<div class="modal">
<div class="modal-header">
<span>New Board</span>
<button class="btn-close" id="btnCancelBoard"></button>
<span data-i18n="modal.new_board">New Board</span>
<button class="btn-close" id="btnCancelBoard" data-i18n-title="dialog.close"></button>
</div>
<div class="modal-body">
<input type="text" class="text-input full-width" id="newBoardName" placeholder="Board name..." maxlength="40" />
<input type="text" class="text-input full-width" id="newBoardName" data-i18n-placeholder="modal.board_name" placeholder="Board name..." maxlength="40" />
</div>
<div class="modal-footer">
<button class="btn-primary" id="btnConfirmBoard">Create</button>
<button class="btn-primary" id="btnConfirmBoard" data-i18n="modal.create">Create</button>
</div>
</div>
</div>
@@ -384,16 +472,16 @@
<div class="modal-overlay" id="addBookmarkOverlay">
<div class="modal">
<div class="modal-header">
<span>New Bookmark</span>
<button class="btn-close" id="btnCancelBookmark"></button>
<span data-i18n="modal.new_bookmark">New Bookmark</span>
<button class="btn-close" id="btnCancelBookmark" data-i18n-title="dialog.close"></button>
</div>
<div class="modal-body">
<input type="text" class="text-input full-width" id="newBmTitle" placeholder="Title..." maxlength="60" />
<input type="text" class="text-input full-width" id="newBmTitle" data-i18n-placeholder="modal.bm_title" placeholder="Title..." maxlength="60" />
<input type="url" class="text-input full-width modal-input-spaced" id="newBmUrl" placeholder="https://..." />
<input type="text" class="text-input full-width modal-input-spaced" id="newBmDesc" placeholder="Description (optional)" />
<input type="text" class="text-input full-width modal-input-spaced" id="newBmDesc" data-i18n-placeholder="modal.bm_desc" placeholder="Description (optional)" />
</div>
<div class="modal-footer">
<button class="btn-primary" id="btnConfirmBookmark">Add</button>
<button class="btn-primary" id="btnConfirmBookmark" data-i18n="modal.bm_add">Add</button>
</div>
</div>
</div>
@@ -402,14 +490,14 @@
<div class="modal-overlay" id="renameOverlay">
<div class="modal">
<div class="modal-header">
<span>Rename</span>
<button class="btn-close" id="btnCancelRename"></button>
<span data-i18n="modal.rename">Rename</span>
<button class="btn-close" id="btnCancelRename" data-i18n-title="dialog.close"></button>
</div>
<div class="modal-body">
<input type="text" class="text-input full-width" id="renameInput" placeholder="New name..." maxlength="60" />
<input type="text" class="text-input full-width" id="renameInput" data-i18n-placeholder="modal.rename_placeholder" placeholder="New name..." maxlength="60" />
</div>
<div class="modal-footer">
<button class="btn-primary" id="btnConfirmRename">Rename</button>
<button class="btn-primary" id="btnConfirmRename" data-i18n="modal.rename_confirm">Rename</button>
</div>
</div>
</div>
@@ -422,7 +510,10 @@
<!-- Storage muss zuerst -->
<script src="src/js/storage.js"></script>
<!-- State & Hilfsfunktionen -->
<script src="src/js/quicksave-core.js"></script>
<script src="src/js/state.js"></script>
<!-- i18n (nach state.js, vor allen Modulen die t() nutzen könnten) -->
<script src="src/js/i18n.js"></script>
<!-- Dialog-System (vor Features, wird überall gebraucht) -->
<script src="src/js/dialog.js"></script>
<!-- Theme-System -->
@@ -432,7 +523,18 @@
<script src="src/js/boards.js"></script>
<script src="src/js/settings.js"></script>
<script src="src/js/search.js"></script>
<script src="src/js/sticky.js"></script>
<script src="src/js/palette.js"></script>
<script src="src/js/widgets.js"></script>
<script src="src/js/notes.js"></script>
<script src="src/js/calculator.js"></script>
<script src="src/js/calc-scientific.js"></script>
<script src="src/js/calc-converter.js"></script>
<script src="src/js/calc-satisfactory.js"></script>
<script src="src/js/calc-factorio.js"></script>
<script src="src/js/calc-stationeers.js"></script>
<script src="src/js/timer.js"></script>
<script src="src/js/image-ref.js"></script>
<script src="src/js/bookmark-import.js"></script>
<script src="src/js/data.js"></script>
<!-- Onboarding -->
<script src="src/js/onboarding.js"></script>
+55
View File
@@ -0,0 +1,55 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":dependencyDashboard",
":semanticCommits",
":timezone(Europe/Berlin)",
"schedule:weekly"
],
"labels": ["dependencies", "renovate"],
"assignees": ["JonKazama-Hellion"],
"prHourlyLimit": 10,
"prConcurrentLimit": 20,
"rebaseWhen": "behind-base-branch",
"packageRules": [
{
"description": "Group all minor and patch updates per ecosystem in one PR",
"matchUpdateTypes": ["minor", "patch"],
"groupName": "minor and patch updates ({{manager}})"
},
{
"description": "Major updates always get their own PR with breaking-change label",
"matchUpdateTypes": ["major"],
"labels": ["dependencies", "major-update", "breaking-change"],
"addLabels": ["needs-review"]
},
{
"description": "TypeScript type definitions stay grouped with each other",
"groupName": "type definitions",
"matchPackageNames": [
"@types/{/,}**"
]
},
{
"description": "Dev dependencies in their own group",
"matchDepTypes": ["devDependencies"],
"groupName": "dev dependencies"
},
{
"description": "Pin GitHub Action versions by SHA for supply-chain hygiene",
"matchManagers": ["github-actions"],
"pinDigests": true
}
],
"vulnerabilityAlerts": {
"labels": ["security", "vulnerability"],
"schedule": ["at any time"]
},
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 6am on monday"],
"commitMessageAction": "Refresh"
},
"osvVulnerabilityAlerts": true
}
-101
View File
@@ -1,101 +0,0 @@
# ⬡ Hellion Dashboard — Design & Theme-System
Leitfaden für das visuelle Design des Hellion Dashboards. Definiert wie Themes aufgebaut
sind und welche Patterns konsistent eingehalten werden — für eine immersive, fokussierte
Nutzererfahrung.
---
## Design-Säulen
| Säule | Beschreibung |
|---|---|
| **Immersion** | Das Interface wirkt wie ein HUD das über der Szenerie schwebt — kein Fremdkörper |
| **Visual Clarity** | Gezielter `blur`-Einsatz trennt UI und Hintergrundbild — reduziert Reizüberflutung |
| **Harmonie** | Jedes Theme zieht seine Farben aus den dominanten Lichtquellen des Hintergrundbildes |
---
## Anatomie eines Themes
Jedes Theme folgt dieser Variablen-Struktur in `main.css`.
Für ein neues Theme diesen Block kopieren und anpassen:
```css
[data-theme="dein-theme-name"] {
/* 1. AKZENTE — Die Lichtquelle */
--accent: #HEXCODE; /* Hauptfarbe (Neon/Licht) */
--accent-dim: rgba(R, G, B, 0.12); /* Subtiler Hintergrund */
--accent-glow: rgba(R, G, B, 0.08); /* Glow für Logo & Uhr */
--border-accent: rgba(R, G, B, 0.25); /* Fokus-Rahmen */
/* 2. BASIS — Das Fundament */
--bg-primary: #HEXCODE; /* Dunkelster Punkt im Bild */
--bg-board: rgba(R, G, B, 0.55); /* Glas-Effekt der Boards */
/* 3. TEXT — Kontrast */
--text-primary: #FFFFFF; /* Klar lesbar, leicht getönt */
--text-secondary: #A0A0A0; /* Entsättigt für weniger Rauschen */
/* 4. OVERLAY — Vignette */
--overlay-bg: radial-gradient(
circle at center,
transparent 0%,
var(--bg-primary) 100%
);
}
```
---
## UI-Patterns
### Frosted Glass
Hardware-beschleunigter Blur für Lesbarkeit auf komplexen Hintergründen:
```css
backdrop-filter: blur(8px);
```
Erzeugt Tiefe und visuelle Ruhe hinter Text und UI-Elementen.
### Typografie-Hierarchie
| Font | Einsatz |
|---|---|
| **Rajdhani** | Display — Uhr, Titel, Logo. Alles was nach "System" aussieht |
| **Inter** | Body — Bookmark-Titel, Listen, interaktive Elemente |
| **Cinzel** | Fantasy — Exklusiv für Themes mit majestätischem oder antikem Vibe (Crescent, Julia & Jin) |
---
## Theme-Übersicht
| Theme | Akzentfarbe | Stimmung |
|---|---|---|
| Nebula | `#b359ff` Magenta | Chill, Cosmic |
| Crescent | `#d4bd8a` Gold | Luxury, Night |
| Event Horizon | `#9d5cff` Purple | Deep Space, Void |
| Merchantman | `#2eb8b8` Emerald | Industrial, Alien |
| Julia & Jin | `#7db3ff` Aetherial Blue | FFXIV Night |
| SC Sunset | `#ff8c3d` Amber | Emotional, Horizon |
| Hellion HUD | `#32ff6a` Neon Green | Tactical, Admin |
| Hellion Energy | `#1eff8e` Acid Green | Overdrive, Power |
---
## ADHS-Optimierung
Bei Hintergrundbildern mit vielen Details (z.B. Julia & Jin) den Board-Alpha erhöhen
und den Blur verstärken — das dimmt das Hintergrundrauschen und lässt das Gehirn
schneller die relevanten Informationen erfassen:
```css
--bg-board: rgba(R, G, B, 0.65);
backdrop-filter: blur(12px);
```
---
Entwickelt von **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion
+1495 -128
View File
File diff suppressed because it is too large Load Diff
+167 -12
View File
@@ -6,17 +6,37 @@
async function init() {
const savedBoards = await Store.get('boards');
const savedSettings = await Store.get('settings');
const savedTrash = await Store.get('trash');
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);
I18n.init();
applySettings();
renderBoards();
startClock();
bindGlobalEvents();
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();
initStickyNote();
initPalette();
await migrateSticky();
await Notes.init();
await Calculator.init();
await Timer.init();
await ImageRef.init();
BrowserBookmarkImport.init();
initDataButtons();
Store.checkQuota();
@@ -30,6 +50,46 @@ async function init() {
}
}
// ---- STICKY NOTE MIGRATION ----
async function migrateSticky() {
const stickyText = await Store.get('stickyNote');
const stickyPos = await Store.get('stickyPos');
const existingWidgets = await Store.get('widgetStates');
// Nur migrieren wenn alte Daten vorhanden UND noch keine Widgets existieren
if (!stickyText && !stickyPos) return;
if (existingWidgets && Array.isArray(existingWidgets.notes) && existingWidgets.notes.length > 0) return;
const noteData = {
id: 'note_' + uid(),
title: (stickyText || '').split('\n')[0].trim().slice(0, 20) || 'Note',
content: stickyText || '',
template: 'text',
x: stickyPos ? stickyPos.x : 120,
y: stickyPos ? stickyPos.y : 80,
width: 280,
height: 220,
open: true,
checkedItems: [],
checklistItems: []
};
await Store.set('widgetStates', { notes: [noteData] });
// Alte Keys aufraeumen
try {
if (typeof chrome !== 'undefined' && chrome.storage) {
chrome.storage.local.remove(['stickyNote', 'stickyPos', 'stickyVisible']);
} else {
localStorage.removeItem('stickyNote');
localStorage.removeItem('stickyPos');
localStorage.removeItem('stickyVisible');
}
} catch (e) {
console.warn('Sticky-Migration: Alte Keys konnten nicht entfernt werden', e);
}
}
// ---- BACKUP REMINDER ----
const BACKUP_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage
@@ -49,13 +109,17 @@ async function checkBackupReminder() {
if (boards.length === 0) return;
const doBackup = await HellionDialog.confirm(
'Du hast seit über einer Woche kein Backup gemacht. Beim Löschen der Browserdaten gehen deine Boards verloren. Jetzt sichern?',
{ type: 'warning', title: 'Backup-Erinnerung', confirmText: 'Jetzt sichern', cancelText: 'Später' }
t('app.backup_reminder'),
{ type: 'warning', title: t('app.backup_reminder.title'), confirmText: t('app.backup_now'), cancelText: t('app.backup_later') }
);
if (doBackup) {
// JSON-Export auslösen (gleiche Logik wie btnExportJSON)
const data = { version: '1.5.2', exported: new Date().toISOString(), boards, settings };
const widgetData = await Store.get('widgetStates');
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : [];
const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : [];
const data = { version: '2.3.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 url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -71,18 +135,18 @@ async function checkBackupReminder() {
// ---- CLOCK & DATE ----
function startClock() {
const DAYS = ['So','Mo','Di','Mi','Do','Fr','Sa'];
const MONTHS = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
const DAY_KEYS = ['clock.days.sun','clock.days.mon','clock.days.tue','clock.days.wed','clock.days.thu','clock.days.fri','clock.days.sat'];
const MONTH_KEYS = ['clock.months.jan','clock.months.feb','clock.months.mar','clock.months.apr','clock.months.may','clock.months.jun','clock.months.jul','clock.months.aug','clock.months.sep','clock.months.oct','clock.months.nov','clock.months.dec'];
function tick() {
const now = new Date();
document.getElementById('clock').textContent =
`${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
document.getElementById('date').textContent =
`${DAYS[now.getDay()]}, ${String(now.getDate()).padStart(2,'0')}. ${MONTHS[now.getMonth()]}`;
`${t(DAY_KEYS[now.getDay()])}, ${String(now.getDate()).padStart(2,'0')}. ${t(MONTH_KEYS[now.getMonth()])}`;
}
tick();
setInterval(tick, 1000);
const clockInterval = setInterval(tick, 1000);
}
// ---- GLOBALE EVENTS (Header-Buttons, Modals, Import) ----
@@ -99,7 +163,7 @@ function bindGlobalEvents() {
if (!file) return;
const imported = parseBookmarkHtml(await file.text());
if (imported.length === 0) {
await HellionDialog.alert('Keine Bookmarks in dieser Datei gefunden.', { type: 'warning', title: 'Import' });
await HellionDialog.alert(t('app.no_bookmarks'), { type: 'warning', title: t('app.import_title') });
return;
}
boards = [...boards, ...imported];
@@ -107,8 +171,8 @@ function bindGlobalEvents() {
renderBoards();
e.target.value = '';
await HellionDialog.alert(
`${imported.length} Board(s) mit ${imported.reduce((s,b) => s + b.bookmarks.length, 0)} Bookmarks importiert.`,
{ type: 'success', title: 'Import erfolgreich' }
t('app.html_import_success', { count: imported.length, total: imported.reduce((s,b) => s + b.bookmarks.length, 0) }),
{ type: 'success', title: t('app.import_success_title') }
);
});
@@ -140,7 +204,7 @@ function bindGlobalEvents() {
const url = document.getElementById('newBmUrl').value.trim();
const desc = document.getElementById('newBmDesc').value.trim();
if (!title || !url) return;
try { new URL(url); } catch { await HellionDialog.alert('Ungültige URL. Bitte mit https:// beginnen.', { type: 'warning', title: 'URL ungültig' }); return; }
try { new URL(url); } catch { await HellionDialog.alert(t('app.invalid_url'), { type: 'warning', title: t('app.invalid_url.title') }); return; }
const board = boards.find(b => b.id === pendingBookmarkBoardId);
if (!board) return;
board.bookmarks.push({ id: uid(), title, url, desc });
@@ -170,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);
+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));
}
});
+154 -44
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 ----
function renderBoards() {
const wrapper = document.getElementById('boardsWrapper');
@@ -57,26 +85,42 @@ function renderBoards() {
const boardStrong = document.createElement('strong');
boardStrong.className = 'accent-text';
boardStrong.textContent = '+ Board';
boardStrong.textContent = t('boards.add_board');
const importStrong = document.createElement('strong');
importStrong.className = 'accent-text';
importStrong.textContent = 'Import';
importStrong.textContent = t('boards.import');
empty.append(
'No boards yet. Click ', boardStrong, ' to create one, or use ', importStrong, ' to load your browser bookmarks.'
t('boards.empty_state_pre'), boardStrong, t('boards.empty_state_mid'), importStrong, t('boards.empty_state_post')
);
wrapper.appendChild(empty);
return;
}
boards.forEach(board => wrapper.appendChild(createBoardEl(board)));
// 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();
if (migrated) saveBoards();
}
function createBoardEl(board) {
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;
// Header
@@ -85,7 +129,7 @@ function createBoardEl(board) {
const dragHandle = document.createElement('span');
dragHandle.className = 'board-drag-handle';
dragHandle.title = 'Board verschieben';
dragHandle.title = t('boards.drag_title');
dragHandle.appendChild(createDragHandleSvg());
const titleSpanHeader = document.createElement('span');
@@ -96,22 +140,34 @@ function createBoardEl(board) {
const actions = document.createElement('div');
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');
btnBlur.className = 'board-action-btn btn-blur-board';
btnBlur.title = board.blurred ? 'Unblur' : 'Blur (privat)';
btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur');
btnBlur.textContent = '\uD83D\uDD12';
const btnRename = document.createElement('button');
btnRename.className = 'board-action-btn btn-rename-board';
btnRename.title = 'Umbenennen';
btnRename.title = t('boards.rename');
btnRename.textContent = '\u270E';
const btnDelete = document.createElement('button');
btnDelete.className = 'board-action-btn btn-delete-board';
btnDelete.title = 'Löschen';
btnDelete.textContent = '\u2715';
// Das feste Inbox-Board (Quick-Save-Ziel) darf nicht geloescht werden \u2014 kein Delete-Button.
const btnDelete = board.id === 'inbox' ? null : document.createElement('button');
if (btnDelete) {
btnDelete.className = 'board-action-btn btn-delete-board';
btnDelete.title = t('boards.delete');
btnDelete.textContent = '\u2715';
}
actions.append(btnBlur, btnRename, btnDelete);
if (btnDelete) {
actions.append(btnLock, btnBlur, btnRename, btnDelete);
} else {
actions.append(btnLock, btnBlur, btnRename);
}
header.append(dragHandle, titleSpanHeader, actions);
// Blur-Overlay
@@ -119,18 +175,28 @@ function createBoardEl(board) {
blurOverlay.className = 'board-blur-overlay';
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 => {
e.stopPropagation();
board.blurred = !board.blurred;
div.classList.toggle('blurred', board.blurred);
btnBlur.title = board.blurred ? 'Unblur' : 'Blur (privat)';
btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur');
await saveBoards();
});
blurOverlay.addEventListener('click', async () => {
board.blurred = false;
div.classList.remove('blurred');
btnBlur.title = 'Blur (privat)';
btnBlur.title = t('boards.blur');
await saveBoards();
});
@@ -144,15 +210,29 @@ function createBoardEl(board) {
});
});
btnDelete.addEventListener('click', async e => {
if (btnDelete) btnDelete.addEventListener('click', async e => {
e.stopPropagation();
const ok = await HellionDialog.confirm(
`Board "${board.title}" wirklich löschen?`,
{ type: 'danger', title: 'Board löschen', confirmText: 'Löschen' }
t('boards.delete_confirm', { title: board.title }),
{ type: 'danger', title: t('boards.delete_confirm.title'), confirmText: t('boards.delete') }
);
if (ok) {
boards = boards.filter(b => b.id !== board.id);
await saveBoards();
// Ganzes board-Objekt (inkl. bookmarks UND blurred-Flag, CR-01) in den Papierkorb.
// type:'board', kein originBoardId (Board hat keine Herkunft, Restore legt es direkt in boards[]).
// Datensicherheit: ZUERST Trash sichern (saveTrash), DANN Loeschung committen (saveBoards) —
// bei Quota-Reject bleibt das Board in boards[], kein Datenverlust.
const trashEntry = pushToTrash({ item: board, type: 'board', originBoardId: null });
try {
await saveTrash();
boards = boards.filter(b => b.id !== board.id);
await saveBoards();
} catch (err) {
// Save fehlgeschlagen (z.B. Quota genau zwischen den Writes): auf den Vor-Loesch-Stand
// zurueckrollen, damit In-Memory und Storage konsistent bleiben (kein Reload-Duplikat).
trash = trash.filter(t => t !== trashEntry);
if (!boards.some(b => b.id === board.id)) boards.push(board);
console.error('Board-Loeschen fehlgeschlagen, zurueckgerollt:', err && err.message);
}
renderBoards();
}
});
@@ -180,16 +260,16 @@ function createBoardEl(board) {
let hiddenEls = [];
const showMoreBtn = document.createElement('button');
showMoreBtn.className = 'show-more-btn';
showMoreBtn.textContent = `Show ${hidden.length} more…`;
showMoreBtn.textContent = t('boards.show_more', { count: hidden.length });
showMoreBtn.addEventListener('click', () => {
if (!expanded) {
hidden.forEach(bm => { const el = createBmEl(bm); hiddenEls.push(el); list.appendChild(el); });
showMoreBtn.textContent = 'Show less';
showMoreBtn.textContent = t('boards.show_less');
expanded = true;
} else {
hiddenEls.forEach(el => el.remove());
hiddenEls = [];
showMoreBtn.textContent = `Show ${hidden.length} more…`;
showMoreBtn.textContent = t('boards.show_more', { count: hidden.length });
expanded = false;
}
});
@@ -200,7 +280,7 @@ function createBoardEl(board) {
const addBtn = document.createElement('button');
addBtn.className = 'add-bm-btn';
addBtn.appendChild(createPlusSvg());
addBtn.append(' Add link');
addBtn.append(t('boards.add_link'));
addBtn.addEventListener('click', () => openAddBookmarkModal(board.id));
div.appendChild(addBtn);
@@ -214,20 +294,15 @@ function createBmEl(bm) {
li.dataset.bmId = bm.id;
li.dataset.bmUrl = bm.url;
li.draggable = true;
li.setAttribute('role', 'link');
li.setAttribute('tabindex', '0');
li.setAttribute('aria-label', bm.title);
const favicon = document.createElement('img');
favicon.className = 'bm-favicon';
favicon.width = 14;
favicon.height = 14;
favicon.src = getFaviconUrl(bm.url);
favicon.addEventListener('error', function() {
this.classList.add('hidden');
this.nextElementSibling.classList.remove('hidden');
});
const fallback = document.createElement('div');
fallback.className = 'bm-favicon-fallback hidden';
fallback.textContent = bm.title.charAt(0).toUpperCase();
const favicon = document.createElement('div');
favicon.className = 'bm-favicon-local';
favicon.textContent = bm.title.charAt(0).toUpperCase();
const hue = (bm.title.charCodeAt(0) * 137) % 360;
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
const textDiv = document.createElement('div');
textDiv.className = 'bm-text';
@@ -243,11 +318,10 @@ function createBmEl(bm) {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'bm-delete';
deleteBtn.title = 'Entfernen';
deleteBtn.title = t('boards.remove_bookmark');
deleteBtn.textContent = '✕';
li.appendChild(favicon);
li.appendChild(fallback);
li.appendChild(textDiv);
li.appendChild(deleteBtn);
@@ -260,12 +334,28 @@ function bindBoardListEvents(list, board) {
const bmItem = e.target.closest('.bm-item');
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')) {
e.stopPropagation();
const bmId = bmItem.dataset.bmId;
board.bookmarks = board.bookmarks.filter(b => b.id !== bmId);
await saveBoards();
const removed = board.bookmarks.find(b => b.id === bmId);
if (removed) {
// Datensicherheit: ZUERST den Trash-Klon persistieren, DANN die Loeschung committen.
// Falls saveTrash() (Quota) rejectet, ist das Original noch in boards[] -> kein Verlust.
const trashEntry = pushToTrash({ item: removed, type: 'bookmark', originBoardId: board.id });
try {
await saveTrash();
board.bookmarks = board.bookmarks.filter(b => b.id !== bmId);
await saveBoards();
} catch (err) {
// Save fehlgeschlagen: auf den Vor-Loesch-Stand zurueckrollen (kein Reload-Duplikat).
trash = trash.filter(t => t !== trashEntry);
if (!board.bookmarks.some(b => b.id === bmId)) board.bookmarks.push(removed);
console.error('Bookmark-Loeschen fehlgeschlagen, zurueckgerollt:', err && err.message);
}
}
renderBoards();
return;
}
@@ -276,11 +366,31 @@ function bindBoardListEvents(list, board) {
window.open(url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
}
});
// Tastatur: Enter oeffnet den Bookmark wie ein Klick. role="link" erwartet
// nur Enter (Space ist Button-Semantik). Der Delete-Button bleibt ein echter
// <button> und feuert seinen eigenen Klick ueber Space/Enter selbst.
list.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
const bmItem = e.target.closest('.bm-item');
if (!bmItem || e.target !== bmItem) return; // nur wenn der li selbst fokussiert ist
e.preventDefault();
const url = bmItem.dataset.bmUrl;
if (url) {
window.open(url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
}
});
}
// ---- MODALS ----
function openModal(id) { document.getElementById(id).classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
// reduced-motion kappt das Fade ueber den ungeschichteten @media-Block.
// Feature-Detection-Fallback (Firefox < 144): instant.
function openModal(id) {
withViewTransition(() => document.getElementById(id).classList.add('active'));
}
function closeModal(id) {
withViewTransition(() => document.getElementById(id).classList.remove('active'));
}
function openAddBoardModal() {
document.getElementById('newBoardName').value = '';
+305
View File
@@ -0,0 +1,305 @@
/* =============================================
HELLION NEWTAB — bookmark-import.js
Direkt-Import von Browser-Lesezeichen
via chrome.bookmarks.getTree() / browser.bookmarks.getTree()
============================================= */
const BrowserBookmarkImport = {
/** Initialisiert den Import-Button */
init() {
const btn = document.getElementById('btnBrowserImport');
const row = document.getElementById('browserImportRow');
if (!btn || !row) return;
// API-Verfuegbarkeit pruefen (nicht vorhanden im normalen Browser-Tab)
const api = this._getApi();
if (!api) {
row.style.display = 'none';
return;
}
btn.addEventListener('click', () => this._openFolderModal());
},
/**
* Gibt die Bookmarks-API zurueck (Chrome oder Firefox)
* @returns {object|null}
*/
_getApi() {
if (typeof chrome !== 'undefined' && chrome.bookmarks) return chrome.bookmarks;
if (typeof browser !== 'undefined' && browser.bookmarks) return browser.bookmarks;
return null;
},
/** Oeffnet das Ordner-Auswahl Modal */
async _openFolderModal() {
const api = this._getApi();
if (!api) return;
let tree;
try {
tree = await api.getTree();
} catch (err) {
await HellionDialog.alert(
t('bm_import.no_access'),
{ type: 'warning', title: t('bm_import.title') }
);
return;
}
const folders = this._extractFolders(tree[0]);
if (folders.length === 0) {
await HellionDialog.alert(
t('bm_import.no_folders'),
{ type: 'warning', title: t('bm_import.title') }
);
return;
}
this._renderModal(folders);
},
/**
* Extrahiert alle Ordner rekursiv aus dem Bookmark-Baum
* @param {object} node - Bookmark-Tree Node
* @param {number} depth - Einrueckungstiefe
* @returns {Array}
*/
_extractFolders(node, depth) {
if (depth === undefined) depth = 0;
const result = [];
if (!node.children) return result;
for (const child of node.children) {
if (child.children) {
const bookmarkCount = child.children.filter(function(c) { return c.url; }).length;
const subfolderCount = child.children.filter(function(c) { return c.children; }).length;
result.push({
id: child.id,
title: child.title || t('bm_import.unnamed'),
depth: depth,
bookmarkCount: bookmarkCount,
subfolderCount: subfolderCount,
node: child
});
const subFolders = this._extractFolders(child, depth + 1);
for (const sf of subFolders) {
result.push(sf);
}
}
}
return result;
},
/**
* Rendert das Ordner-Auswahl Modal
* @param {Array} folders - Liste der Ordner
*/
_renderModal(folders) {
// Overlay
const overlay = document.createElement('div');
overlay.className = 'bm-import-overlay';
overlay.id = 'bmImportOverlay';
const modal = document.createElement('div');
modal.className = 'bm-import-modal';
// Header
const header = document.createElement('div');
header.className = 'bm-import-header';
const title = document.createElement('span');
title.textContent = t('bm_import.modal_title');
header.appendChild(title);
const closeBtn = document.createElement('button');
closeBtn.className = 'bm-import-close';
closeBtn.textContent = '\u00D7';
closeBtn.addEventListener('click', () => this._closeModal());
header.appendChild(closeBtn);
modal.appendChild(header);
// Info
const info = document.createElement('div');
info.className = 'bm-import-info';
info.textContent = t('bm_import.info');
modal.appendChild(info);
// Ordner-Liste
const list = document.createElement('div');
list.className = 'bm-import-list';
for (const folder of folders) {
const row = document.createElement('label');
row.className = 'bm-import-folder';
row.style.paddingLeft = (12 + folder.depth * 20) + 'px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'bm-import-checkbox';
checkbox.dataset.folderId = folder.id;
row.appendChild(checkbox);
const label = document.createElement('span');
label.className = 'bm-import-folder-name';
label.textContent = folder.title;
row.appendChild(label);
const meta = document.createElement('span');
meta.className = 'bm-import-folder-meta';
const parts = [];
if (folder.bookmarkCount > 0) {
parts.push(t('bm_import.link_count', { count: folder.bookmarkCount }));
}
if (folder.subfolderCount > 0) {
parts.push(t('bm_import.folder_count', { count: folder.subfolderCount }));
}
if (parts.length === 0) {
parts.push(t('bm_import.empty'));
}
meta.textContent = parts.join(', ');
row.appendChild(meta);
list.appendChild(row);
}
modal.appendChild(list);
// Footer
const footer = document.createElement('div');
footer.className = 'bm-import-footer';
const selectAll = document.createElement('button');
selectAll.className = 'btn-secondary';
selectAll.textContent = t('bm_import.select_all');
selectAll.addEventListener('click', () => {
const boxes = list.querySelectorAll('.bm-import-checkbox');
const allChecked = Array.from(boxes).every(function(cb) { return cb.checked; });
boxes.forEach(function(cb) { cb.checked = !allChecked; });
selectAll.textContent = allChecked ? t('bm_import.select_all') : t('bm_import.deselect_all');
});
footer.appendChild(selectAll);
const importBtn = document.createElement('button');
importBtn.className = 'btn-primary';
importBtn.textContent = t('bm_import.import_btn');
importBtn.addEventListener('click', () => this._importSelected(folders));
footer.appendChild(importBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
// View-Transition-Fade
withViewTransition(() => overlay.classList.add('active'));
},
/** Schliesst das Modal */
_closeModal() {
const overlay = document.getElementById('bmImportOverlay');
if (!overlay) return;
withViewTransition(() => {
overlay.classList.remove('active');
overlay.remove();
});
},
/**
* Importiert die ausgewaehlten Ordner als Boards
* @param {Array} folders - Alle Ordner
*/
async _importSelected(folders) {
const checkboxes = document.querySelectorAll('.bm-import-checkbox:checked');
if (checkboxes.length === 0) {
await HellionDialog.alert(
t('bm_import.no_selection'),
{ type: 'warning', title: t('bm_import.title') }
);
return;
}
// Bestehende URLs sammeln fuer Duplikat-Erkennung
const existingUrls = new Set();
for (const board of boards) {
for (const bm of board.bookmarks) {
existingUrls.add(bm.url);
}
}
const selectedIds = new Set();
checkboxes.forEach(function(cb) { selectedIds.add(cb.dataset.folderId); });
let totalImported = 0;
let totalSkipped = 0;
let boardsCreated = 0;
for (const folder of folders) {
if (!selectedIds.has(folder.id)) continue;
const bookmarks = [];
for (const child of folder.node.children) {
if (!child.url) continue;
// Nur http/https URLs
try {
const parsed = new URL(child.url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
} catch (e) {
continue;
}
// Duplikat-Check
if (existingUrls.has(child.url)) {
totalSkipped++;
continue;
}
bookmarks.push({
id: uid(),
title: child.title || child.url,
url: child.url,
desc: ''
});
existingUrls.add(child.url);
totalImported++;
}
if (bookmarks.length === 0) continue;
boards.push({
id: uid(),
title: folder.title,
bookmarks: bookmarks,
blurred: false
});
boardsCreated++;
}
if (boardsCreated > 0) {
await saveBoards();
renderBoards();
}
this._closeModal();
// Ergebnis-Dialog
const lines = [];
lines.push(t('bm_import.boards_created', { count: boardsCreated }));
lines.push(t('bm_import.bookmarks_imported', { count: totalImported }));
if (totalSkipped > 0) {
lines.push(t('bm_import.duplicates_skipped', { count: totalSkipped }));
}
await HellionDialog.alert(
lines.join('\n'),
{ type: 'success', title: t('bm_import.success_title') }
);
}
};
+344
View File
@@ -0,0 +1,344 @@
/* =============================================
HELLION NEWTAB — calc-converter.js
Unit-Converter Modus für Calculator Widget
============================================= */
(function() {
'use strict';
const CATEGORIES = {
length: {
titleKey: 'calculator.conv.cat.length',
baseUnit: 'm',
units: {
mm: { toBase: v => v / 1000, fromBase: v => v * 1000 },
cm: { toBase: v => v / 100, fromBase: v => v * 100 },
m: { toBase: v => v, fromBase: v => v },
km: { toBase: v => v * 1000, fromBase: v => v / 1000 },
in: { toBase: v => v * 0.0254, fromBase: v => v / 0.0254 },
ft: { toBase: v => v * 0.3048, fromBase: v => v / 0.3048 },
yd: { toBase: v => v * 0.9144, fromBase: v => v / 0.9144 },
mi: { toBase: v => v * 1609.344, fromBase: v => v / 1609.344 }
}
},
weight: {
titleKey: 'calculator.conv.cat.weight',
baseUnit: 'g',
units: {
mg: { toBase: v => v / 1000, fromBase: v => v * 1000 },
g: { toBase: v => v, fromBase: v => v },
kg: { toBase: v => v * 1000, fromBase: v => v / 1000 },
t: { toBase: v => v * 1000000, fromBase: v => v / 1000000 },
oz: { toBase: v => v * 28.3495, fromBase: v => v / 28.3495 },
lb: { toBase: v => v * 453.592, fromBase: v => v / 453.592 }
}
},
temperature: {
titleKey: 'calculator.conv.cat.temperature',
baseUnit: null,
units: { '\u00B0C': null, '\u00B0F': null, 'K': null },
convert(value, from, to) {
if (from === to) return value;
const key = from + '_' + to;
const conversions = {
'\u00B0C_\u00B0F': v => (v * 9 / 5) + 32,
'\u00B0C_K': v => v + 273.15,
'\u00B0F_\u00B0C': v => (v - 32) * 5 / 9,
'\u00B0F_K': v => (v - 32) * 5 / 9 + 273.15,
'K_\u00B0C': v => v - 273.15,
'K_\u00B0F': v => (v - 273.15) * 9 / 5 + 32
};
const fn = conversions[key];
return fn ? fn(value) : null;
}
},
volume: {
titleKey: 'calculator.conv.cat.volume',
baseUnit: 'ml',
units: {
ml: { toBase: v => v, fromBase: v => v },
L: { toBase: v => v * 1000, fromBase: v => v / 1000 },
'm\u00B3':{ toBase: v => v * 1000000, fromBase: v => v / 1000000 },
'gal(US)':{ toBase: v => v * 3785.41, fromBase: v => v / 3785.41 },
'gal(UK)':{ toBase: v => v * 4546.09, fromBase: v => v / 4546.09 },
'ft\u00B3':{ toBase: v => v * 28316.8, fromBase: v => v / 28316.8 }
}
},
speed: {
titleKey: 'calculator.conv.cat.speed',
baseUnit: 'm/s',
units: {
'm/s': { toBase: v => v, fromBase: v => v },
'km/h': { toBase: v => v / 3.6, fromBase: v => v * 3.6 },
'mph': { toBase: v => v * 0.44704, fromBase: v => v / 0.44704 },
'kn': { toBase: v => v * 0.514444, fromBase: v => v / 0.514444 }
}
},
area: {
titleKey: 'calculator.conv.cat.area',
baseUnit: 'm\u00B2',
units: {
'mm\u00B2': { toBase: v => v / 1000000, fromBase: v => v * 1000000 },
'cm\u00B2': { toBase: v => v / 10000, fromBase: v => v * 10000 },
'm\u00B2': { toBase: v => v, fromBase: v => v },
'km\u00B2': { toBase: v => v * 1000000, fromBase: v => v / 1000000 },
'ha': { toBase: v => v * 10000, fromBase: v => v / 10000 },
'acre': { toBase: v => v * 4046.86, fromBase: v => v / 4046.86 },
'ft\u00B2': { toBase: v => v * 0.092903, fromBase: v => v / 0.092903 },
'in\u00B2': { toBase: v => v * 0.00064516, fromBase: v => v / 0.00064516 }
}
}
};
const CATEGORY_ORDER = ['length', 'weight', 'temperature', 'volume', 'speed', 'area'];
let _currentCategory = 'length';
let _fromUnit = 'cm';
let _toUnit = 'in';
let _fromInput = null;
let _toInput = null;
let _refEl = null;
/**
* Converts a value from one unit to another within the current category.
* @param {number} value
* @param {string} from
* @param {string} to
* @returns {number|null}
*/
function convert(value, from, to) {
const cat = CATEGORIES[_currentCategory];
if (!cat) return null;
if (cat.convert) return cat.convert(value, from, to);
const fromDef = cat.units[from];
const toDef = cat.units[to];
if (!fromDef || !toDef) return null;
const base = fromDef.toBase(value);
return toDef.fromBase(base);
}
/**
* Recalculates the output field and reference lines based on current input.
*/
function recalc() {
if (!_fromInput || !_toInput) return;
const val = parseFloat(_fromInput.value);
if (isNaN(val)) {
_toInput.value = '';
updateReference();
return;
}
const result = convert(val, _fromUnit, _toUnit);
if (result === null) {
_toInput.value = '';
} else {
_toInput.value = Calculator._formatResult(result);
}
updateReference();
}
/**
* Updates the reference conversion lines below the inputs.
*/
function updateReference() {
if (!_refEl) return;
_refEl.textContent = '';
const r1 = convert(1, _fromUnit, _toUnit);
const r2 = convert(1, _toUnit, _fromUnit);
if (r1 !== null) {
const line1 = document.createElement('div');
line1.textContent = '1 ' + _fromUnit + ' = ' + Calculator._formatResult(r1) + ' ' + _toUnit;
_refEl.appendChild(line1);
}
if (r2 !== null) {
const line2 = document.createElement('div');
line2.textContent = '1 ' + _toUnit + ' = ' + Calculator._formatResult(r2) + ' ' + _fromUnit;
_refEl.appendChild(line2);
}
}
/**
* Populates a unit <select> element with options for the current category.
* @param {HTMLSelectElement} selectEl
* @param {string} selectedUnit
*/
function populateUnitSelect(selectEl, selectedUnit) {
while (selectEl.firstChild) {
selectEl.removeChild(selectEl.firstChild);
}
const cat = CATEGORIES[_currentCategory];
if (!cat) return;
const units = Object.keys(cat.units);
units.forEach(unit => {
const opt = document.createElement('option');
opt.value = unit;
opt.textContent = unit;
if (unit === selectedUnit) opt.selected = true;
selectEl.appendChild(opt);
});
}
/**
* Returns sensible default from/to units for a given category key.
* @param {string} catKey
* @returns {{ from: string, to: string }}
*/
function getDefaultUnits(catKey) {
const defaults = {
length: { from: 'cm', to: 'in' },
weight: { from: 'kg', to: 'lb' },
temperature: { from: '\u00B0C', to: '\u00B0F' },
volume: { from: 'L', to: 'gal(US)' },
speed: { from: 'km/h', to: 'mph' },
area: { from: 'm\u00B2', to: 'ft\u00B2' }
};
return defaults[catKey] || { from: Object.keys(CATEGORIES[catKey].units)[0], to: Object.keys(CATEGORIES[catKey].units)[1] };
}
/**
* Loads persisted converter state from storage.
*/
async function loadState() {
const data = await Store.get(Calculator.STORAGE_KEY);
if (data && data.calculator && data.calculator.converter) {
const s = data.calculator.converter;
if (s.lastCategory && CATEGORIES[s.lastCategory]) _currentCategory = s.lastCategory;
if (s.fromUnit) _fromUnit = s.fromUnit;
if (s.toUnit) _toUnit = s.toUnit;
}
}
/**
* Persists current converter state to storage (read-before-write).
*/
async function saveState() {
const data = await Store.get(Calculator.STORAGE_KEY) || {};
if (!data.calculator) data.calculator = {};
data.calculator.converter = {
lastCategory: _currentCategory,
fromUnit: _fromUnit,
toUnit: _toUnit
};
await Store.set(Calculator.STORAGE_KEY, data);
}
/**
* Builds the converter UI and appends it to the widget body element.
* @param {HTMLElement} bodyEl
*/
function buildUI(bodyEl) {
const catSelect = document.createElement('select');
catSelect.className = 'calc-conv-select';
CATEGORY_ORDER.forEach(catKey => {
const opt = document.createElement('option');
opt.value = catKey;
opt.textContent = t(CATEGORIES[catKey].titleKey);
if (catKey === _currentCategory) opt.selected = true;
catSelect.appendChild(opt);
});
const fromRow = document.createElement('div');
fromRow.className = 'calc-conv-row';
_fromInput = document.createElement('input');
_fromInput.type = 'number';
_fromInput.className = 'calc-conv-input';
_fromInput.placeholder = '0';
_fromInput.step = 'any';
const fromSelect = document.createElement('select');
fromSelect.className = 'calc-conv-unit';
populateUnitSelect(fromSelect, _fromUnit);
fromRow.append(_fromInput, fromSelect);
const swapBtn = document.createElement('button');
swapBtn.type = 'button';
swapBtn.className = 'calc-conv-swap';
swapBtn.textContent = '\u21C5';
swapBtn.title = t('calculator.conv.swap');
const toRow = document.createElement('div');
toRow.className = 'calc-conv-row';
_toInput = document.createElement('input');
_toInput.type = 'text';
_toInput.className = 'calc-conv-input';
_toInput.readOnly = true;
_toInput.placeholder = '0';
const toSelect = document.createElement('select');
toSelect.className = 'calc-conv-unit';
populateUnitSelect(toSelect, _toUnit);
toRow.append(_toInput, toSelect);
_refEl = document.createElement('div');
_refEl.className = 'calc-conv-ref';
_fromInput.addEventListener('input', () => recalc());
fromSelect.addEventListener('change', () => {
_fromUnit = fromSelect.value;
recalc();
saveState();
});
toSelect.addEventListener('change', () => {
_toUnit = toSelect.value;
recalc();
saveState();
});
swapBtn.addEventListener('click', () => {
const tmpUnit = _fromUnit;
_fromUnit = _toUnit;
_toUnit = tmpUnit;
populateUnitSelect(fromSelect, _fromUnit);
populateUnitSelect(toSelect, _toUnit);
const currentVal = _toInput.value;
if (currentVal) {
_fromInput.value = currentVal;
}
recalc();
saveState();
});
catSelect.addEventListener('change', () => {
_currentCategory = catSelect.value;
const defaults = getDefaultUnits(_currentCategory);
_fromUnit = defaults.from;
_toUnit = defaults.to;
populateUnitSelect(fromSelect, _fromUnit);
populateUnitSelect(toSelect, _toUnit);
_fromInput.value = '';
_toInput.value = '';
updateReference();
saveState();
});
bodyEl.append(catSelect, fromRow, swapBtn, toRow, _refEl);
updateReference();
}
Calculator.registerMode('converter', {
label: '⚖️',
shortName: 'Unit',
titleKey: 'calculator.tab.converter',
render(bodyEl) {
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.gap = '8px';
loadState().then(() => {
buildUI(bodyEl);
});
},
destroy() {
_fromInput = null;
_toInput = null;
_refEl = null;
saveState();
}
});
})();
+247
View File
@@ -0,0 +1,247 @@
/* =============================================
HELLION NEWTAB — calc-factorio.js
Factorio Calculator Modus
============================================= */
(function() {
'use strict';
const ASSEMBLERS = [
{ key: 'asm1', speed: 0.5 },
{ key: 'asm2', speed: 0.75 },
{ key: 'asm3', speed: 1.25 }
];
const BELTS = [
{ key: 'yellow', throughput: 15, perSide: 7.5 },
{ key: 'red', throughput: 30, perSide: 15 },
{ key: 'blue', throughput: 45, perSide: 22.5 }
];
const SUB_MODES = ['ratio', 'belt', 'machines'];
let _activeSubMode = 'ratio';
function createAssemblerSelect(selectedKey) {
const row = document.createElement('div');
row.className = 'calc-game-field';
const label = document.createElement('label');
label.textContent = t('calculator.fac.assembler');
const select = document.createElement('select');
select.className = 'calc-game-input';
ASSEMBLERS.forEach(asm => {
const opt = document.createElement('option');
opt.value = asm.key;
opt.textContent = t('calculator.fac.asm.' + asm.key) + ' (' + asm.speed + 'x)';
if (asm.key === selectedKey) opt.selected = true;
select.appendChild(opt);
});
row.append(label, select);
return { row, select };
}
function createBeltSelect(selectedKey) {
const row = document.createElement('div');
row.className = 'calc-game-field';
const label = document.createElement('label');
label.textContent = t('calculator.fac.belt');
const select = document.createElement('select');
select.className = 'calc-game-input';
BELTS.forEach(belt => {
const opt = document.createElement('option');
opt.value = belt.key;
opt.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + belt.throughput + '/s)';
if (belt.key === selectedKey) opt.selected = true;
select.appendChild(opt);
});
row.append(label, select);
return { row, select };
}
function getAssemblerSpeed(key) {
const asm = ASSEMBLERS.find(a => a.key === key);
return asm ? asm.speed : 1;
}
function getBelt(key) {
return BELTS.find(b => b.key === key) || BELTS[0];
}
function findSmallestBelt(throughput) {
for (const belt of BELTS) {
if (belt.throughput >= throughput) return belt;
}
return null;
}
function createField(labelKey, defaultVal, opts) {
opts = opts || {};
const row = document.createElement('div');
row.className = 'calc-game-field';
const label = document.createElement('label');
label.textContent = t(labelKey);
const input = document.createElement('input');
input.type = 'number';
input.className = 'calc-game-input';
input.value = defaultVal;
if (opts.step) input.step = opts.step;
if (opts.min !== undefined) input.min = opts.min;
row.append(label, input);
return { row, input };
}
function createOutput(labelKey) {
const row = document.createElement('div');
row.className = 'calc-game-output';
const label = document.createElement('span');
label.textContent = t(labelKey);
const value = document.createElement('span');
value.className = 'calc-game-value';
row.append(label, value);
return { row, value };
}
function renderRatio(container) {
const asmSelect = createAssemblerSelect('asm3');
const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });
const perSecOutput = createOutput('calculator.fac.items_per_sec');
const perMinOutput = createOutput('calculator.fac.items_per_min');
function calc() {
const speed = getAssemblerSpeed(asmSelect.select.value);
const output = parseFloat(outputField.input.value) || 0;
const time = parseFloat(timeField.input.value) || 1;
const perSec = output * speed / time;
const perMin = perSec * 60;
perSecOutput.value.textContent = Calculator._formatResult(perSec) + ' /s';
perMinOutput.value.textContent = Calculator._formatResult(perMin) + ' /min';
}
[outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
asmSelect.select.addEventListener('change', calc);
container.append(asmSelect.row, outputField.row, timeField.row, perSecOutput.row, perMinOutput.row);
calc();
}
function renderBelt(container) {
const beltSelect = createBeltSelect('yellow');
const consumeField = createField('calculator.fac.consume_per_sec', 1, { step: 0.1, min: 0.1 });
const machinesOutput = createOutput('calculator.fac.machines_per_belt');
const utilOutput = createOutput('calculator.fac.belt_utilization');
function calc() {
const belt = getBelt(beltSelect.select.value);
const consume = parseFloat(consumeField.input.value) || 1;
const machines = Math.floor(belt.throughput / consume);
const util = (consume * machines) / belt.throughput * 100;
machinesOutput.value.textContent = machines;
utilOutput.value.textContent = Calculator._formatResult(util) + '%';
}
consumeField.input.addEventListener('input', calc);
beltSelect.select.addEventListener('change', calc);
container.append(beltSelect.row, consumeField.row, machinesOutput.row, utilOutput.row);
calc();
}
function renderMachines(container) {
const asmSelect = createAssemblerSelect('asm3');
const targetField = createField('calculator.fac.target_output_sec', 10, { step: 0.1, min: 0.1 });
const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });
const machinesOutput = createOutput('calculator.fac.machines_needed');
const beltOutput = createOutput('calculator.fac.belt_needed');
function calc() {
const speed = getAssemblerSpeed(asmSelect.select.value);
const target = parseFloat(targetField.input.value) || 0;
const output = parseFloat(outputField.input.value) || 1;
const time = parseFloat(timeField.input.value) || 1;
const perMachine = output * speed / time;
const machines = perMachine > 0 ? Math.ceil(target / perMachine) : 0;
const totalThroughput = machines * perMachine;
const belt = findSmallestBelt(totalThroughput);
machinesOutput.value.textContent = machines;
if (belt) {
const util = (totalThroughput / belt.throughput) * 100;
beltOutput.value.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + Calculator._formatResult(util) + '%)';
} else {
beltOutput.value.textContent = t('calculator.fac.exceeds_belt');
}
}
[targetField, outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
asmSelect.select.addEventListener('change', calc);
container.append(asmSelect.row, targetField.row, outputField.row, timeField.row, machinesOutput.row, beltOutput.row);
calc();
}
async function loadState() {
const data = await Store.get(Calculator.STORAGE_KEY);
if (data && data.calculator && data.calculator.factorio) {
const s = data.calculator.factorio;
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
}
}
async function saveState() {
const data = await Store.get(Calculator.STORAGE_KEY) || {};
if (!data.calculator) data.calculator = {};
data.calculator.factorio = { lastSubMode: _activeSubMode };
await Store.set(Calculator.STORAGE_KEY, data);
}
function renderSubMode(container) {
container.textContent = '';
switch (_activeSubMode) {
case 'ratio': renderRatio(container); break;
case 'belt': renderBelt(container); break;
case 'machines': renderMachines(container); break;
}
}
Calculator.registerMode('factorio', {
label: '🏭',
shortName: 'FAC',
titleKey: 'calculator.tab.factorio',
render(bodyEl) {
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.gap = '8px';
loadState().then(() => {
const subContent = document.createElement('div');
subContent.className = 'calc-game-content';
const bar = document.createElement('div');
bar.className = 'calc-game-subtabs';
SUB_MODES.forEach(mode => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
btn.textContent = t('calculator.fac.tab.' + mode);
btn.dataset.mode = mode;
btn.addEventListener('click', () => {
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_activeSubMode = mode;
renderSubMode(subContent);
saveState();
});
bar.appendChild(btn);
});
bodyEl.append(bar, subContent);
renderSubMode(subContent);
});
},
destroy() {
saveState();
}
});
})();
+184
View File
@@ -0,0 +1,184 @@
/* =============================================
HELLION NEWTAB — calc-satisfactory.js
Satisfactory Calculator Modus
============================================= */
(function() {
'use strict';
const POWER_EXPONENT = 1.321928;
const SUB_MODES = ['itemsPerMin', 'power', 'machines'];
let _activeSubMode = 'itemsPerMin';
function createField(labelKey, defaultVal, opts) {
opts = opts || {};
const row = document.createElement('div');
row.className = 'calc-game-field';
const label = document.createElement('label');
label.textContent = t(labelKey);
const input = document.createElement('input');
input.type = 'number';
input.className = 'calc-game-input';
input.value = defaultVal;
if (opts.step) input.step = opts.step;
if (opts.min !== undefined) input.min = opts.min;
if (opts.max !== undefined) input.max = opts.max;
row.append(label, input);
return { row, input };
}
function createOutput(labelKey) {
const row = document.createElement('div');
row.className = 'calc-game-output';
const label = document.createElement('span');
label.textContent = t(labelKey);
const value = document.createElement('span');
value.className = 'calc-game-value';
row.append(label, value);
return { row, value };
}
function renderItemsPerMin(container) {
const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
const output = createOutput('calculator.sat.output_per_min');
function calc() {
const items = parseFloat(itemsField.input.value) || 0;
const time = parseFloat(timeField.input.value) || 1;
const clock = parseFloat(clockField.input.value) || 100;
const result = (items * 60) / time * (clock / 100);
output.value.textContent = Calculator._formatResult(result) + ' items/min';
}
[itemsField, timeField, clockField].forEach(f => f.input.addEventListener('input', calc));
container.append(itemsField.row, timeField.row, clockField.row, output.row);
calc();
}
function renderPower(container) {
const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
const powerOutput = createOutput('calculator.sat.power_usage');
const effOutput = createOutput('calculator.sat.efficiency');
function calc() {
const basePower = parseFloat(basePowerField.input.value) || 0;
const clock = parseFloat(clockField.input.value) || 100;
const ratio = clock / 100;
const power = basePower * Math.pow(ratio, POWER_EXPONENT);
const effPerItem = Math.pow(ratio, POWER_EXPONENT - 1);
powerOutput.value.textContent = Calculator._formatResult(power) + ' MW';
if (clock > 100) {
const overhead = (effPerItem - 1) * 100;
effOutput.value.textContent = '+' + Calculator._formatResult(overhead) + '% ' + t('calculator.sat.per_item');
effOutput.row.style.display = '';
} else {
effOutput.row.style.display = 'none';
}
}
[basePowerField, clockField].forEach(f => f.input.addEventListener('input', calc));
container.append(basePowerField.row, clockField.row, powerOutput.row, effOutput.row);
calc();
}
function renderMachines(container) {
const targetField = createField('calculator.sat.target_output', 60, { step: 1, min: 1 });
const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });
const machinesOutput = createOutput('calculator.sat.machines_needed');
const totalPowerOutput = createOutput('calculator.sat.total_power');
function calc() {
const target = parseFloat(targetField.input.value) || 0;
const items = parseFloat(itemsField.input.value) || 1;
const time = parseFloat(timeField.input.value) || 1;
const clock = parseFloat(clockField.input.value) || 100;
const basePower = parseFloat(basePowerField.input.value) || 0;
const ratio = clock / 100;
const itemsPerMin = (items * 60) / time * ratio;
const machines = itemsPerMin > 0 ? Math.ceil(target / itemsPerMin) : 0;
const totalPower = machines * basePower * Math.pow(ratio, POWER_EXPONENT);
machinesOutput.value.textContent = machines;
totalPowerOutput.value.textContent = Calculator._formatResult(totalPower) + ' MW';
}
[targetField, itemsField, timeField, clockField, basePowerField].forEach(f => f.input.addEventListener('input', calc));
container.append(targetField.row, itemsField.row, timeField.row, clockField.row, basePowerField.row, machinesOutput.row, totalPowerOutput.row);
calc();
}
async function loadState() {
const data = await Store.get(Calculator.STORAGE_KEY);
if (data && data.calculator && data.calculator.satisfactory) {
const s = data.calculator.satisfactory;
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
}
}
async function saveState() {
const data = await Store.get(Calculator.STORAGE_KEY) || {};
if (!data.calculator) data.calculator = {};
data.calculator.satisfactory = { lastSubMode: _activeSubMode };
await Store.set(Calculator.STORAGE_KEY, data);
}
function renderSubMode(container) {
container.textContent = '';
switch (_activeSubMode) {
case 'itemsPerMin': renderItemsPerMin(container); break;
case 'power': renderPower(container); break;
case 'machines': renderMachines(container); break;
}
}
Calculator.registerMode('satisfactory', {
label: '⚙️',
shortName: 'SAT',
titleKey: 'calculator.tab.satisfactory',
render(bodyEl) {
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.gap = '8px';
loadState().then(() => {
const subContent = document.createElement('div');
subContent.className = 'calc-game-content';
const bar = document.createElement('div');
bar.className = 'calc-game-subtabs';
SUB_MODES.forEach(mode => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
btn.textContent = t('calculator.sat.tab.' + mode);
btn.dataset.mode = mode;
btn.addEventListener('click', () => {
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_activeSubMode = mode;
renderSubMode(subContent);
saveState();
});
bar.appendChild(btn);
});
bodyEl.append(bar, subContent);
renderSubMode(subContent);
});
},
destroy() {
saveState();
}
});
})();
+294
View File
@@ -0,0 +1,294 @@
/* =============================================
HELLION NEWTAB — calc-scientific.js
Scientific-Modus für Calculator Widget
============================================= */
(function() {
'use strict';
const FORMULAS = [
{
key: 'circle_area',
fields: [{ key: 'radius', default: '' }],
calc: (vals) => Math.PI * vals.radius * vals.radius
},
{
key: 'circle_circumference',
fields: [{ key: 'radius', default: '' }],
calc: (vals) => 2 * Math.PI * vals.radius
},
{
key: 'celsius_to_fahrenheit',
fields: [{ key: 'temp', default: '' }],
calc: (vals) => (vals.temp * 9 / 5) + 32
},
{
key: 'fahrenheit_to_celsius',
fields: [{ key: 'temp', default: '' }],
calc: (vals) => (vals.temp - 32) * 5 / 9
},
{
key: 'pythagoras',
fields: [{ key: 'a', default: '' }, { key: 'b', default: '' }],
calc: (vals) => Math.sqrt(vals.a * vals.a + vals.b * vals.b)
},
{
key: 'percentage',
fields: [{ key: 'value', default: '' }, { key: 'percent', default: '' }],
calc: (vals) => vals.value * vals.percent / 100
}
];
let _keyboardExtHandler = null;
function renderSciButtons(container) {
const grid = document.createElement('div');
grid.className = 'calc-buttons calc-sci-buttons';
const buttons = [
['√', 'sqrt', 'operator'],
['x²', 'square', 'operator'],
['xⁿ', 'power', 'operator'],
['π', 'pi', 'operator'],
['e', 'euler', 'operator'],
['±', 'negate', 'operator']
];
buttons.forEach(([label, value, cls]) => {
const btn = document.createElement('button');
btn.className = 'calc-btn' + (cls ? ' ' + cls : '');
btn.textContent = label;
btn.type = 'button';
btn.addEventListener('click', () => handleSciKey(value));
grid.appendChild(btn);
});
container.appendChild(grid);
}
function handleSciKey(key) {
switch (key) {
case 'sqrt':
if (!Calculator._currentExpr && Calculator._lastResult) {
Calculator._currentExpr = 'sqrt(' + Calculator._lastResult + ')';
Calculator._lastResult = '';
Calculator._updateDisplay();
break;
}
Calculator._currentExpr += 'sqrt(';
Calculator._updateDisplay();
break;
case 'square':
if (!Calculator._currentExpr && Calculator._lastResult) {
Calculator._currentExpr = Calculator._lastResult;
Calculator._lastResult = '';
}
Calculator._currentExpr += '^2';
Calculator._updateDisplay();
break;
case 'power':
Calculator._handleKey('^');
break;
case 'pi':
Calculator._currentExpr += '3.14159265359';
Calculator._updateDisplay();
break;
case 'euler':
Calculator._currentExpr += '2.71828182846';
Calculator._updateDisplay();
break;
case 'negate':
handleNegate();
break;
}
}
function handleNegate() {
const expr = Calculator._currentExpr;
if (!expr && Calculator._lastResult) {
const num = parseFloat(Calculator._lastResult);
if (!isNaN(num)) {
Calculator._currentExpr = String(-num);
Calculator._lastResult = '';
Calculator._updateDisplay();
}
return;
}
const match = expr.match(/(-?\d*\.?\d+)$/);
if (match) {
const num = parseFloat(match[1]);
const negated = String(-num);
Calculator._currentExpr = expr.slice(0, expr.length - match[1].length) + negated;
Calculator._updateDisplay();
}
}
function renderFormulaHelper(container) {
const wrapper = document.createElement('div');
wrapper.className = 'calc-formula-helper';
const label = document.createElement('div');
label.className = 'calc-formula-label';
label.textContent = t('calculator.sci.formulas');
const select = document.createElement('select');
select.className = 'calc-formula-select';
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = t('calculator.sci.select_formula');
select.appendChild(emptyOpt);
FORMULAS.forEach((f, i) => {
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = t('calculator.sci.formula.' + f.key);
select.appendChild(opt);
});
const inputsContainer = document.createElement('div');
inputsContainer.className = 'calc-formula-inputs';
const resultContainer = document.createElement('div');
resultContainer.className = 'calc-formula-result';
select.addEventListener('change', () => {
while (inputsContainer.firstChild) {
inputsContainer.removeChild(inputsContainer.firstChild);
}
resultContainer.textContent = '';
const idx = parseInt(select.value, 10);
if (isNaN(idx)) return;
const formula = FORMULAS[idx];
renderFormulaInputs(formula, inputsContainer, resultContainer);
});
wrapper.append(label, select, inputsContainer, resultContainer);
container.appendChild(wrapper);
}
function renderFormulaInputs(formula, inputsEl, resultEl) {
const inputs = {};
formula.fields.forEach(field => {
const row = document.createElement('div');
row.className = 'calc-formula-row';
const lbl = document.createElement('label');
lbl.textContent = t('calculator.sci.field.' + field.key);
const inp = document.createElement('input');
inp.type = 'number';
inp.className = 'calc-formula-input';
inp.placeholder = '0';
inp.step = 'any';
inputs[field.key] = inp;
inp.addEventListener('input', () => {
recalcFormula(formula, inputs, resultEl);
});
row.append(lbl, inp);
inputsEl.appendChild(row);
});
}
function recalcFormula(formula, inputs, resultEl) {
const vals = {};
let allValid = true;
for (const field of formula.fields) {
const v = parseFloat(inputs[field.key].value);
if (isNaN(v)) { allValid = false; break; }
vals[field.key] = v;
}
if (!allValid) {
resultEl.textContent = '';
return;
}
const result = formula.calc(vals);
if (result === null || !isFinite(result)) {
resultEl.textContent = t('calculator.error');
return;
}
resultEl.textContent = '= ' + Calculator._formatResult(result);
}
function bindSciKeyboard(widgetEl) {
_keyboardExtHandler = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.target.contentEditable === 'true') return;
if (e.key === 'p') {
handleSciKey('pi');
e.preventDefault();
e.stopPropagation();
} else if (e.key === '^') {
handleSciKey('power');
e.preventDefault();
e.stopPropagation();
}
};
widgetEl.addEventListener('keydown', _keyboardExtHandler);
}
Calculator.registerMode('scientific', {
label: '📐',
shortName: 'Sci',
titleKey: 'calculator.tab.scientific',
render(bodyEl) {
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.flex = '1';
bodyEl.style.overflow = 'hidden';
const display = document.createElement('div');
display.className = 'calc-display';
const exprEl = document.createElement('div');
exprEl.className = 'calc-expression';
Calculator._displayExprEl = exprEl;
const resultEl = document.createElement('div');
resultEl.className = 'calc-result';
resultEl.textContent = Calculator._lastResult || '0';
Calculator._displayResultEl = resultEl;
display.append(exprEl, resultEl);
const sciSection = document.createElement('div');
renderSciButtons(sciSection);
const stdButtons = Calculator._createButtons();
const historyEl = Calculator._createHistoryPanel();
const formulaSection = document.createElement('div');
renderFormulaHelper(formulaSection);
bodyEl.append(display, sciSection, stdButtons, historyEl, formulaSection);
Calculator._updateDisplay();
const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
if (entry) bindSciKeyboard(entry.el);
},
destroy() {
if (_keyboardExtHandler) {
const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
if (entry) {
entry.el.removeEventListener('keydown', _keyboardExtHandler);
}
_keyboardExtHandler = null;
}
Calculator._displayExprEl = null;
Calculator._displayResultEl = null;
}
});
})();
+361
View File
@@ -0,0 +1,361 @@
/* =============================================
HELLION NEWTAB — calc-stationeers.js
Stationeers Calculator Modus
============================================= */
(function() {
'use strict';
const R = 8314.46261815324;
const COMBUSTION_ENERGY = 563452;
const HEAT_CAP_PURE_FUEL = 61.9;
const HEAT_CAP_DELTA = 172.615;
const BATTERY_CAPACITY = 50000;
const HEAT_CAPS = [
{ gas: 'O\u2082', cp: 21.1 },
{ gas: 'H\u2082', cp: 20.4 },
{ gas: 'CO\u2082', cp: 28.2 },
{ gas: 'N\u2082', cp: 20.6 },
{ gas: 'H\u2082O', cp: 72.0 },
{ gas: 'N\u2082O', cp: 23.0 },
{ gas: 'Pollutant', cp: 24.8 }
];
const GAS_VARS = ['P', 'V', 'n', 'T'];
const SUB_MODES = ['gas', 'furnace', 'solar', 'atmo'];
let _activeSubMode = 'gas';
function createField(labelKey, defaultVal, opts) {
opts = opts || {};
const row = document.createElement('div');
row.className = 'calc-game-field';
const label = document.createElement('label');
label.textContent = t(labelKey);
const input = document.createElement('input');
input.type = 'number';
input.className = 'calc-game-input';
input.value = defaultVal;
if (opts.step) input.step = opts.step;
if (opts.min !== undefined) input.min = opts.min;
if (opts.max !== undefined) input.max = opts.max;
if (opts.disabled) input.disabled = true;
row.append(label, input);
return { row, input };
}
function createOutput(labelKey) {
const row = document.createElement('div');
row.className = 'calc-game-output';
const label = document.createElement('span');
label.textContent = t(labelKey);
const value = document.createElement('span');
value.className = 'calc-game-value';
row.append(label, value);
return { row, value };
}
function renderGas(container) {
const solveRow = document.createElement('div');
solveRow.className = 'calc-game-field';
const solveLabel = document.createElement('label');
solveLabel.textContent = t('calculator.sta.solve_for');
const solveSelect = document.createElement('select');
solveSelect.className = 'calc-game-input';
GAS_VARS.forEach(v => {
const opt = document.createElement('option');
opt.value = v;
opt.textContent = t('calculator.sta.var.' + v);
solveSelect.appendChild(opt);
});
solveRow.append(solveLabel, solveSelect);
container.appendChild(solveRow);
const fields = {};
const defaults = { P: 101.325, V: 1000, n: 1, T: 293.15 };
GAS_VARS.forEach(v => {
const f = createField(
'calculator.sta.var.' + v + '_label',
defaults[v],
{ step: 'any' }
);
fields[v] = f;
container.appendChild(f.row);
});
const tempHelper = document.createElement('div');
tempHelper.className = 'calc-game-hint';
container.appendChild(tempHelper);
const resultOutput = createOutput('calculator.sta.result');
container.appendChild(resultOutput.row);
function calc() {
const solveFor = solveSelect.value;
GAS_VARS.forEach(v => {
fields[v].input.disabled = (v === solveFor);
fields[v].input.style.opacity = (v === solveFor) ? '0.5' : '1';
});
const P_kPa = parseFloat(fields.P.input.value) || 0;
const P = P_kPa * 1000;
const V = parseFloat(fields.V.input.value) || 0;
const n = parseFloat(fields.n.input.value) || 0;
const T = parseFloat(fields.T.input.value) || 0;
let result = null;
let unit = '';
switch (solveFor) {
case 'P':
if (V > 0) { result = (n * R * T) / V; result /= 1000; unit = 'kPa'; }
break;
case 'V':
if (P > 0) { result = (n * R * T) / P; unit = 'L'; }
break;
case 'n':
if (R * T > 0) { result = (P * V) / (R * T); unit = 'mol'; }
break;
case 'T':
if (n * R > 0) { result = (P * V) / (n * R); unit = 'K'; }
break;
}
if (result !== null && isFinite(result)) {
fields[solveFor].input.value = Calculator._formatResult(result);
resultOutput.value.textContent = Calculator._formatResult(result) + ' ' + unit;
} else {
resultOutput.value.textContent = '-';
}
const tempVal = parseFloat(fields.T.input.value) || 0;
tempHelper.textContent = Calculator._formatResult(tempVal - 273.15) + ' \u00B0C';
}
GAS_VARS.forEach(v => {
fields[v].input.addEventListener('input', calc);
});
solveSelect.addEventListener('change', calc);
calc();
}
function renderFurnace(container) {
const fuelField = createField('calculator.sta.fuel_ratio', 0.5, { step: 0.01, min: 0, max: 1 });
const tempField = createField('calculator.sta.start_temp', 293.15, { step: 1, min: 0 });
const pressField = createField('calculator.sta.start_pressure', 101.325, { step: 0.1, min: 0 });
const tempOutput = createOutput('calculator.sta.temp_after');
const pressOutput = createOutput('calculator.sta.pressure_after');
const warningEl = document.createElement('div');
warningEl.className = 'calc-game-warning';
function calc() {
const fuel = parseFloat(fuelField.input.value) || 0;
const T_vor = parseFloat(tempField.input.value) || 293.15;
const P_vor = parseFloat(pressField.input.value) || 101.325;
warningEl.textContent = '';
if (fuel < 0.05) {
warningEl.textContent = t('calculator.sta.warn_low_fuel');
}
if (P_vor < 10) {
warningEl.textContent += (warningEl.textContent ? ' ' : '') + t('calculator.sta.warn_low_pressure');
}
const specificHeat = HEAT_CAP_PURE_FUEL;
const T_nach = (T_vor * specificHeat + fuel * COMBUSTION_ENERGY) / (specificHeat + fuel * HEAT_CAP_DELTA);
const P_nach = P_vor * T_nach * (1 + 5.7 * fuel) / T_vor;
tempOutput.value.textContent = Calculator._formatResult(T_nach) + ' K (' + Calculator._formatResult(T_nach - 273.15) + ' \u00B0C)';
pressOutput.value.textContent = Calculator._formatResult(P_nach) + ' kPa';
}
[fuelField, tempField, pressField].forEach(f => f.input.addEventListener('input', calc));
container.append(fuelField.row, tempField.row, pressField.row, warningEl, tempOutput.row, pressOutput.row);
calc();
}
function renderSolar(container) {
const panelField = createField('calculator.sta.panels', 12, { step: 1, min: 1 });
const wattField = createField('calculator.sta.watts_per_panel', 500, { step: 10, min: 1 });
const dayField = createField('calculator.sta.day_length', 600, { step: 1, min: 1 });
const nightField = createField('calculator.sta.night_length', 600, { step: 1, min: 1 });
const consumeField = createField('calculator.sta.consumption', 2000, { step: 10, min: 0 });
const genOutput = createOutput('calculator.sta.generation');
const surplusOutput = createOutput('calculator.sta.surplus');
const nightOutput = createOutput('calculator.sta.night_energy');
const battOutput = createOutput('calculator.sta.batteries_needed');
function calc() {
const panels = parseFloat(panelField.input.value) || 0;
const wpp = parseFloat(wattField.input.value) || 0;
const nightLen = parseFloat(nightField.input.value) || 0;
const consume = parseFloat(consumeField.input.value) || 0;
const generation = panels * wpp;
const surplus = generation - consume;
const nightEnergy = consume * nightLen;
const batteries = nightEnergy > 0 ? Math.ceil(nightEnergy / BATTERY_CAPACITY) : 0;
genOutput.value.textContent = Calculator._formatResult(generation) + ' W';
surplusOutput.value.textContent = Calculator._formatResult(surplus) + ' W';
if (surplus < 0) {
surplusOutput.value.style.color = 'var(--danger)';
} else {
surplusOutput.value.style.color = '';
}
nightOutput.value.textContent = Calculator._formatResult(nightEnergy) + ' Ws';
battOutput.value.textContent = batteries;
}
[panelField, wattField, dayField, nightField, consumeField].forEach(f => f.input.addEventListener('input', calc));
container.append(panelField.row, wattField.row, dayField.row, nightField.row, consumeField.row,
genOutput.row, surplusOutput.row, nightOutput.row, battOutput.row);
calc();
}
function renderAtmo(container) {
const targetField = createField('calculator.sta.target_temp', 293.15, { step: 1 });
const gas1Field = createField('calculator.sta.gas1_temp', 200, { step: 1 });
const gas2Field = createField('calculator.sta.gas2_temp', 400, { step: 1 });
const m1Output = createOutput('calculator.sta.mixer_input1');
const m2Output = createOutput('calculator.sta.mixer_input2');
function calc() {
const T0 = parseFloat(targetField.input.value) || 0;
const T1 = parseFloat(gas1Field.input.value) || 0;
const T2 = parseFloat(gas2Field.input.value) || 0;
const denom = Math.abs(T1 - T0) + Math.abs(T2 - T0);
if (denom === 0) {
m1Output.value.textContent = '50%';
m2Output.value.textContent = '50%';
return;
}
const M1 = Math.abs(T2 - T0) / denom;
const M2 = 1 - M1;
m1Output.value.textContent = Calculator._formatResult(M1 * 100) + '%';
m2Output.value.textContent = Calculator._formatResult(M2 * 100) + '%';
}
[targetField, gas1Field, gas2Field].forEach(f => f.input.addEventListener('input', calc));
container.append(targetField.row, gas1Field.row, gas2Field.row, m1Output.row, m2Output.row);
calc();
const details = document.createElement('details');
details.className = 'calc-game-details';
const summary = document.createElement('summary');
summary.textContent = t('calculator.sta.heat_cap_ref');
details.appendChild(summary);
const table = document.createElement('table');
table.className = 'calc-game-table';
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
const thGas = document.createElement('th');
thGas.textContent = t('calculator.sta.gas');
const thCp = document.createElement('th');
thCp.textContent = 'Cp (J/mol\u00B7K)';
headerRow.append(thGas, thCp);
thead.appendChild(headerRow);
const tbody = document.createElement('tbody');
HEAT_CAPS.forEach(entry => {
const tr = document.createElement('tr');
const tdGas = document.createElement('td');
tdGas.textContent = entry.gas;
const tdCp = document.createElement('td');
tdCp.textContent = entry.cp;
tr.append(tdGas, tdCp);
tbody.appendChild(tr);
});
table.append(thead, tbody);
details.appendChild(table);
container.appendChild(details);
}
async function loadState() {
const data = await Store.get(Calculator.STORAGE_KEY);
if (data && data.calculator && data.calculator.stationeers) {
const s = data.calculator.stationeers;
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
}
}
async function saveState() {
const data = await Store.get(Calculator.STORAGE_KEY) || {};
if (!data.calculator) data.calculator = {};
data.calculator.stationeers = { lastSubMode: _activeSubMode };
await Store.set(Calculator.STORAGE_KEY, data);
}
function renderSubMode(container) {
container.textContent = '';
switch (_activeSubMode) {
case 'gas': renderGas(container); break;
case 'furnace': renderFurnace(container); break;
case 'solar': renderSolar(container); break;
case 'atmo': renderAtmo(container); break;
}
}
Calculator.registerMode('stationeers', {
label: '\uD83D\uDE80',
shortName: 'STA',
titleKey: 'calculator.tab.stationeers',
render(bodyEl) {
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.gap = '8px';
loadState().then(() => {
const subContent = document.createElement('div');
subContent.className = 'calc-game-content';
const bar = document.createElement('div');
bar.className = 'calc-game-subtabs';
SUB_MODES.forEach(mode => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
btn.textContent = t('calculator.sta.tab.' + mode);
btn.dataset.mode = mode;
btn.addEventListener('click', () => {
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_activeSubMode = mode;
renderSubMode(subContent);
saveState();
});
bar.appendChild(btn);
});
bodyEl.append(bar, subContent);
renderSubMode(subContent);
});
},
destroy() {
saveState();
}
});
})();
+929
View File
@@ -0,0 +1,929 @@
/* =============================================
HELLION NEWTAB — calculator.js
Taschenrechner Widget: Expression-Parsing,
History, Tastatureingabe
============================================= */
const Calculator = {
WIDGET_ID: 'widget_calculator',
STORAGE_KEY: 'widgetStates',
MAX_HISTORY: 10,
/** @type {Array<{expr: string, result: string}>} */
_history: [],
_currentExpr: '',
_lastResult: '',
_isOpen: false,
_displayExprEl: null,
_displayResultEl: null,
_keydownHandler: null,
_modes: new Map(),
_activeMode: 'standard',
_tabBarEl: null,
// ---- MODE REGISTRY ----
/**
* Modus registrieren (wird von externen Mode-Dateien aufgerufen)
* @param {string} name - Eindeutiger Modus-Name
* @param {Object} config - { label, shortName, titleKey, render(bodyEl), destroy() }
*/
registerMode(name, config) {
this._modes.set(name, config);
// Tab-Bar aktualisieren falls Widget bereits offen
if (this._tabBarEl) this._renderTabBar();
},
// ---- STORAGE ----
/**
* Calculator-State aus Storage laden
*/
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.calculator) {
this._history = Array.isArray(data.calculator.history) ? data.calculator.history : [];
if (data.calculator.activeMode) {
this._activeMode = data.calculator.activeMode;
}
}
},
/**
* Calculator-State in Storage speichern
* Bestehende Notes-Daten bleiben erhalten
*/
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
// Widget-Position aus WidgetManager holen
const widgetState = WidgetManager.getState(this.WIDGET_ID);
if (!data.calculator) data.calculator = {};
data.calculator.x = widgetState ? widgetState.x : 400;
data.calculator.y = widgetState ? widgetState.y : 120;
data.calculator.width = widgetState ? widgetState.width : 280;
data.calculator.height = widgetState ? widgetState.height : 400;
data.calculator.open = this._isOpen;
data.calculator.activeMode = this._activeMode;
data.calculator.history = this._history.slice(0, this.MAX_HISTORY);
await Store.set(this.STORAGE_KEY, data);
},
// ---- WIDGET LIFECYCLE ----
/**
* Calculator oeffnen oder in Vordergrund bringen
*/
async open() {
if (this._isOpen) {
WidgetManager.bringToFront(this.WIDGET_ID);
return;
}
// Gespeicherte Position laden
const data = await Store.get(this.STORAGE_KEY);
const saved = (data && data.calculator) ? data.calculator : {};
const widgetId = WidgetManager.create('calculator', {
id: this.WIDGET_ID,
title: t('calculator.title'),
x: saved.x || 400,
y: saved.y || 120,
width: saved.width || 280,
height: saved.height || 400,
open: true
});
const body = WidgetManager.getBody(widgetId);
if (body) this.renderBody(body);
this._isOpen = true;
// Keyboard-Events binden
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) this._bindKeyboard(entry.el);
await this.save();
},
/**
* Calculator toggle: oeffnen oder minimieren
*/
async toggle() {
if (this._isOpen) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry && entry.state.open) {
await WidgetManager.minimize(this.WIDGET_ID);
this._isOpen = false;
await this.save();
} else if (entry) {
await WidgetManager.openWidget(this.WIDGET_ID);
this._isOpen = true;
await this.save();
}
} else {
await this.open();
}
},
/**
* Wird aufgerufen wenn Widget geschlossen wird
*/
async onClose() {
// Aktiven Modus aufräumen
const mode = this._modes.get(this._activeMode);
if (mode && mode.destroy) mode.destroy();
this._isOpen = false;
this._unbindKeyboard();
this._tabBarEl = null;
this._displayExprEl = null;
this._displayResultEl = null;
await this.save();
},
// ---- UI RENDERING ----
/**
* Calculator-Body rendern: Tab-Bar + aktiver Modus
* @param {HTMLElement} bodyEl
*/
renderBody(bodyEl) {
bodyEl.textContent = '';
bodyEl.style.padding = '0';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.height = '100%';
// Tab-Bar
const tabBar = document.createElement('div');
tabBar.className = 'calc-tab-bar';
this._tabBarEl = tabBar;
this._renderTabBar();
// Mode-Body Container
const modeBody = document.createElement('div');
modeBody.className = 'calc-mode-body';
bodyEl.append(tabBar, modeBody);
// Aktiven Modus rendern
const mode = this._modes.get(this._activeMode);
if (mode) {
mode.render(modeBody);
}
},
/**
* Tab-Bar mit Buttons aus _modes Map befüllen
*/
_renderTabBar() {
if (!this._tabBarEl) return;
while (this._tabBarEl.firstChild) {
this._tabBarEl.removeChild(this._tabBarEl.firstChild);
}
this._modes.forEach((config, name) => {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'calc-tab' + (name === this._activeMode ? ' active' : '');
tab.dataset.mode = name;
const icon = document.createElement('span');
icon.className = 'calc-tab-icon';
icon.textContent = config.label;
const label = document.createElement('span');
label.className = 'calc-tab-label';
label.textContent = config.shortName;
tab.append(icon, label);
tab.addEventListener('click', () => this.switchMode(name));
this._tabBarEl.appendChild(tab);
});
},
/**
* Aktiven Tab visuell markieren (ohne Neuaufbau)
*/
_updateTabBar() {
if (!this._tabBarEl) return;
const tabs = this._tabBarEl.querySelectorAll('.calc-tab');
tabs.forEach(tab => {
tab.classList.toggle('active', tab.dataset.mode === this._activeMode);
});
},
/**
* Modus wechseln
* @param {string} name - Ziel-Modus
*/
async switchMode(name) {
if (name === this._activeMode) return;
const mode = this._modes.get(name);
if (!mode) return;
// Alten Modus aufräumen
const oldMode = this._modes.get(this._activeMode);
if (oldMode && oldMode.destroy) oldMode.destroy();
this._activeMode = name;
// Mode-Body leeren und neu rendern
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (!entry) return;
const modeBody = entry.el.querySelector('.calc-mode-body');
if (!modeBody) return;
modeBody.textContent = '';
mode.render(modeBody);
// Tab-UI aktualisieren
this._updateTabBar();
// Auto-Resize für komplexe Modi
const isComplex = name !== 'standard';
if (isComplex && entry) {
const state = entry.state;
if (state) {
const newW = Math.max(state.width, 320);
const newH = Math.max(state.height, 480);
if (newW !== state.width || newH !== state.height) {
entry.el.style.width = newW + 'px';
entry.el.style.height = newH + 'px';
state.width = newW;
state.height = newH;
}
}
}
// Keyboard neu binden
this._unbindKeyboard();
if (name === 'standard' || name === 'scientific') {
if (entry) this._bindKeyboard(entry.el);
}
await this.save();
},
/**
* Standard-Modus UI rendern
* @param {HTMLElement} bodyEl
*/
_renderStandardMode(bodyEl) {
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.flex = '1';
bodyEl.style.overflow = 'hidden';
// Display
const display = document.createElement('div');
display.className = 'calc-display';
const exprEl = document.createElement('div');
exprEl.className = 'calc-expression';
this._displayExprEl = exprEl;
const resultEl = document.createElement('div');
resultEl.className = 'calc-result';
resultEl.textContent = '0';
this._displayResultEl = resultEl;
display.append(exprEl, resultEl);
// Buttons
const buttonsEl = this._createButtons();
// History
const historyEl = this._createHistoryPanel();
bodyEl.append(display, buttonsEl, historyEl);
// Aktuellen State anzeigen
this._updateDisplay();
},
/**
* Button-Grid erstellen (4x5)
* @returns {HTMLElement}
*/
_createButtons() {
const grid = document.createElement('div');
grid.className = 'calc-buttons';
// Button-Layout: [label, value, cssClass]
const buttons = [
['C', 'clear', 'clear'],
['()', 'paren', 'operator'],
['%', '%', 'operator'],
['\u00F7', '/', 'operator'],
['7', '7', ''],
['8', '8', ''],
['9', '9', ''],
['\u00D7', '*', 'operator'],
['4', '4', ''],
['5', '5', ''],
['6', '6', ''],
['\u2212', '-', 'operator'],
['1', '1', ''],
['2', '2', ''],
['3', '3', ''],
['+', '+', 'operator'],
['0', '0', ''],
['.', '.', ''],
['\u232B', 'backspace', ''],
['=', '=', 'equals']
];
buttons.forEach(([label, value, cls]) => {
const btn = document.createElement('button');
btn.className = 'calc-btn' + (cls ? ' ' + cls : '');
btn.textContent = label;
btn.type = 'button';
btn.addEventListener('click', () => this._handleKey(value));
grid.appendChild(btn);
});
return grid;
},
/**
* History-Panel erstellen
* @returns {HTMLElement}
*/
_createHistoryPanel() {
const container = document.createElement('div');
container.className = 'calc-history';
container.id = 'calcHistoryPanel';
const title = document.createElement('div');
title.className = 'calc-history-title';
title.textContent = t('calculator.history');
container.appendChild(title);
this._renderHistoryItems(container);
return container;
},
/**
* History-Items rendern
* @param {HTMLElement} container
*/
_renderHistoryItems(container) {
// Alte Items entfernen (nur die .calc-history-item Elemente)
const oldItems = container.querySelectorAll('.calc-history-item');
oldItems.forEach(item => item.remove());
if (this._history.length === 0) return;
// Neueste zuerst
const reversed = [...this._history].reverse();
reversed.forEach(entry => {
const item = document.createElement('div');
item.className = 'calc-history-item';
const exprSpan = document.createElement('span');
exprSpan.textContent = entry.expr;
const resultSpan = document.createElement('span');
resultSpan.className = 'calc-h-result';
resultSpan.textContent = '= ' + entry.result;
item.append(exprSpan, resultSpan);
// Klick uebernimmt Ergebnis als neue Eingabe
item.addEventListener('click', () => {
this._currentExpr = entry.result;
this._lastResult = '';
this._updateDisplay();
});
container.appendChild(item);
});
},
// ---- INPUT HANDLING ----
/**
* Taste verarbeiten
* @param {string} key
*/
_handleKey(key) {
switch (key) {
case 'clear':
this._currentExpr = '';
this._lastResult = '';
break;
case 'backspace':
this._currentExpr = this._currentExpr.slice(0, -1);
break;
case '=':
this._calculate();
return;
case 'paren': {
// Smarte Klammern: oeffnende wenn noetig, sonst schliessende
const openCount = (this._currentExpr.match(/\(/g) || []).length;
const closeCount = (this._currentExpr.match(/\)/g) || []).length;
const lastChar = this._currentExpr.slice(-1);
if (openCount <= closeCount || /[+\-*/%(]$/.test(lastChar) || this._currentExpr === '') {
this._currentExpr += '(';
} else {
this._currentExpr += ')';
}
break;
}
case '%':
case '+':
case '-':
case '*':
case '/':
case '^': {
// Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen
if (this._lastResult && this._currentExpr === '') {
this._currentExpr = this._lastResult;
this._lastResult = '';
}
// Doppelte Operatoren verhindern (letzten ersetzen)
const last = this._currentExpr.slice(-1);
if (/[+\-*/%^]/.test(last)) {
this._currentExpr = this._currentExpr.slice(0, -1) + key;
} else {
this._currentExpr += key;
}
break;
}
case '.': {
// Doppelten Dezimalpunkt im letzten Zahlenblock verhindern
const parts = this._currentExpr.split(/[+\-*/%()^]/);
const lastPart = parts[parts.length - 1];
if (lastPart && lastPart.includes('.')) break;
this._currentExpr += key;
break;
}
default:
// Ziffern 0-9
if (/^[0-9]$/.test(key)) {
// Wenn ein Ergebnis da ist und User eine Zahl tippt, neue Berechnung starten
if (this._lastResult && this._currentExpr === '') {
this._lastResult = '';
}
this._currentExpr += key;
}
break;
}
this._updateDisplay();
},
/**
* Berechnung ausfuehren
*/
async _calculate() {
if (!this._currentExpr) return;
const result = this._evaluate(this._currentExpr);
if (result === null) {
this._lastResult = t('calculator.error');
this._updateDisplay();
return;
}
const resultStr = this._formatResult(result);
this._addHistory(this._currentExpr, resultStr);
this._lastResult = resultStr;
// Display aktualisieren
if (this._displayExprEl) {
this._displayExprEl.textContent = this._formatExpression(this._currentExpr) + ' =';
}
if (this._displayResultEl) {
this._displayResultEl.textContent = resultStr;
}
this._currentExpr = '';
// History-Panel aktualisieren
const historyPanel = document.getElementById('calcHistoryPanel');
if (historyPanel) this._renderHistoryItems(historyPanel);
await this.save();
},
// ---- EXPRESSION PARSER (Shunting-Yard, KEIN eval!) ----
/**
* Expression sicher auswerten
* @param {string} expr
* @returns {number|null}
*/
_evaluate(expr) {
try {
// Nur erlaubte Zeichen
const sanitized = expr.replace(/[^0-9+\-*/.%()^a-z]/g, '');
if (!sanitized) return null;
const tokens = this._tokenize(sanitized);
if (!tokens) return null;
return this._parseExpression(tokens);
} catch {
return null;
}
},
/**
* Expression in Tokens aufteilen
* @param {string} expr
* @returns {Array|null}
*/
_tokenize(expr) {
const tokens = [];
let i = 0;
while (i < expr.length) {
const ch = expr[i];
// Funktion: sqrt
if (expr.substring(i, i + 4) === 'sqrt') {
tokens.push({ type: 'func', value: 'sqrt' });
i += 4;
continue;
}
// Zahl (inkl. Dezimal)
if (/[0-9.]/.test(ch)) {
let num = '';
while (i < expr.length && /[0-9.]/.test(expr[i])) {
num += expr[i];
i++;
}
const parsed = parseFloat(num);
if (isNaN(parsed)) return null;
tokens.push({ type: 'number', value: parsed });
continue;
}
// Operator
if (/[+\-*/%]/.test(ch)) {
// Negativer Vorzeichen-Check: am Anfang oder nach Operator/oeffnender Klammer
if (ch === '-') {
const prev = tokens[tokens.length - 1];
if (!prev || prev.type === 'op' || (prev.type === 'paren' && prev.value === '(')) {
// Negatives Vorzeichen → als Teil der naechsten Zahl lesen
let num = '-';
i++;
while (i < expr.length && /[0-9.]/.test(expr[i])) {
num += expr[i];
i++;
}
if (num === '-') return null;
const parsed = parseFloat(num);
if (isNaN(parsed)) return null;
tokens.push({ type: 'number', value: parsed });
continue;
}
}
tokens.push({ type: 'op', value: ch });
i++;
continue;
}
// Potenz-Operator
if (ch === '^') {
tokens.push({ type: 'op', value: '^' });
i++;
continue;
}
// Klammern
if (ch === '(' || ch === ')') {
tokens.push({ type: 'paren', value: ch });
i++;
continue;
}
// Unbekannte Buchstaben
if (/[a-z]/.test(ch)) {
return null;
}
// Unbekanntes Zeichen
return null;
}
return tokens;
},
/**
* Rekursiver Descent Parser mit Operator-Precedence
* Hierarchie: parseExpr (+/-) → parseTerm (*\/%) → parsePower (^) → parseFactor
* @param {Array} tokens
* @returns {number|null}
*/
_parseExpression(tokens) {
let pos = 0;
function peek() { return tokens[pos]; }
function consume() { return tokens[pos++]; }
function parseExpr() {
let left = parseTerm();
if (left === null) return null;
while (pos < tokens.length) {
const tk = peek();
if (!tk || tk.type !== 'op' || (tk.value !== '+' && tk.value !== '-')) break;
consume();
const right = parseTerm();
if (right === null) return null;
left = tk.value === '+' ? left + right : left - right;
}
return left;
}
function parseTerm() {
let left = parsePower();
if (left === null) return null;
while (pos < tokens.length) {
const tk = peek();
if (!tk || tk.type !== 'op' || (tk.value !== '*' && tk.value !== '/' && tk.value !== '%')) break;
consume();
const right = parsePower();
if (right === null) return null;
if (tk.value === '*') {
left = left * right;
} else if (tk.value === '/') {
if (right === 0) return null;
left = left / right;
} else {
left = left % right;
}
}
return left;
}
// Power: Factor ('^' Power)? — rechts-assoziativ via Rekursion
function parsePower() {
let base = parseFactor();
if (base === null) return null;
const tk = peek();
if (tk && tk.type === 'op' && tk.value === '^') {
consume();
const exp = parsePower(); // Rechts-assoziativ!
if (exp === null) return null;
return Math.pow(base, exp);
}
return base;
}
// Factor: func '(' Expression ')' | Number | '(' Expression ')'
function parseFactor() {
const tk = peek();
if (!tk) return null;
// Funktion: sqrt(...)
if (tk.type === 'func') {
const funcName = tk.value;
consume();
const open = peek();
if (!open || open.type !== 'paren' || open.value !== '(') return null;
consume();
const val = parseExpr();
if (val === null) return null;
const close = peek();
if (close && close.type === 'paren' && close.value === ')') {
consume();
}
if (funcName === 'sqrt') {
if (val < 0) return null; // Negativer Radikand nicht erlaubt
return Math.sqrt(val);
}
return null;
}
if (tk.type === 'number') {
consume();
return tk.value;
}
if (tk.type === 'paren' && tk.value === '(') {
consume();
const val = parseExpr();
if (val === null) return null;
const closing = peek();
if (closing && closing.type === 'paren' && closing.value === ')') {
consume();
}
return val;
}
return null;
}
const result = parseExpr();
// Alle Tokens muessen verbraucht sein
if (pos < tokens.length) return null;
if (result === null || !isFinite(result)) return null;
return result;
},
// ---- FORMATTING ----
/**
* Ergebnis formatieren (maximal 10 Dezimalstellen, trailing Nullen entfernen)
* @param {number} num
* @returns {string}
*/
_formatResult(num) {
if (Number.isInteger(num)) return num.toString();
// Maximal 10 Dezimalstellen, trailing Nullen weg
const str = num.toFixed(10).replace(/\.?0+$/, '');
return str;
},
/**
* Expression fuer Anzeige formatieren (× statt *, ÷ statt /)
* @param {string} expr
* @returns {string}
*/
_formatExpression(expr) {
return expr
.replace(/\*/g, '\u00D7')
.replace(/\//g, '\u00F7')
.replace(/sqrt\(/g, '\u221A(');
},
// ---- DISPLAY ----
/**
* Display aktualisieren
*/
_updateDisplay() {
if (this._displayExprEl) {
if (this._lastResult) {
// Ergebnis-Modus: Expression oben, Ergebnis gross
// (wird von _calculate() direkt gesetzt)
} else {
this._displayExprEl.textContent = '';
}
}
if (this._displayResultEl) {
if (this._lastResult && this._currentExpr === '') {
this._displayResultEl.textContent = this._lastResult;
} else {
this._displayResultEl.textContent = this._formatExpression(this._currentExpr) || '0';
}
}
},
// ---- HISTORY ----
/**
* History-Eintrag hinzufuegen
* @param {string} expr
* @param {string} result
*/
_addHistory(expr, result) {
this._history.push({
expr: this._formatExpression(expr),
result: result
});
// Limit einhalten
if (this._history.length > this.MAX_HISTORY) {
this._history = this._history.slice(-this.MAX_HISTORY);
}
},
// ---- KEYBOARD ----
/**
* Tastatur-Events binden
* @param {HTMLElement} widgetEl
*/
_bindKeyboard(widgetEl) {
this._unbindKeyboard();
this._keydownHandler = (e) => {
// Nur reagieren wenn Calculator-Widget fokussiert ist
// (d.h. nicht wenn User in Textarea/Input tippt)
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
if (e.target.contentEditable === 'true') return;
const key = e.key;
let handled = false;
if (/^[0-9]$/.test(key)) {
this._handleKey(key);
handled = true;
} else if (key === '+' || key === '-' || key === '*' || key === '/') {
this._handleKey(key);
handled = true;
} else if (key === '.') {
this._handleKey('.');
handled = true;
} else if (key === '%') {
this._handleKey('%');
handled = true;
} else if (key === '(' || key === ')') {
this._handleKey('paren');
handled = true;
} else if (key === 'Enter' || key === '=') {
this._handleKey('=');
handled = true;
} else if (key === 'Backspace') {
this._handleKey('backspace');
handled = true;
} else if (key === 'Escape' || key === 'c' || key === 'C') {
this._handleKey('clear');
handled = true;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
widgetEl.addEventListener('keydown', this._keydownHandler);
// Widget fokussierbar machen
widgetEl.tabIndex = 0;
widgetEl.focus();
},
/**
* Keyboard-Events entfernen
*/
_unbindKeyboard() {
if (this._keydownHandler) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) {
entry.el.removeEventListener('keydown', this._keydownHandler);
}
this._keydownHandler = null;
}
},
// ---- INIT ----
/**
* Calculator initialisieren (aus app.js aufgerufen)
*/
async init() {
await this.load();
// Standard-Modus ZUERST registrieren, bevor open() aufgerufen wird
this._modes.set('standard', {
label: '🔢',
shortName: 'Std',
titleKey: 'calculator.tab.standard',
render: (bodyEl) => this._renderStandardMode(bodyEl),
destroy: () => {
this._displayExprEl = null;
this._displayResultEl = null;
}
});
// Wenn Calculator beim letzten Mal offen war, wiederherstellen
const data = await Store.get(this.STORAGE_KEY);
if (data && data.calculator && data.calculator.open) {
await this.open();
}
// Widget-Lifecycle-Events
const self = this;
WidgetManager.on('widget:close', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self.onClose();
}
});
WidgetManager.on('widget:minimize', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self._isOpen = false;
self.save();
}
});
WidgetManager.on('widget:open', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self._isOpen = true;
const body = WidgetManager.getBody(self.WIDGET_ID);
if (body && body.children.length === 0) {
self.renderBody(body);
}
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
if (entry) self._bindKeyboard(entry.el);
self.save();
}
});
}
};
+179 -21
View File
@@ -9,9 +9,45 @@ function initDataButtons() {
const jsonInput = document.getElementById('jsonImportInput');
if (!btnExport || !btnImport) return;
// Export
btnExport.addEventListener('click', () => {
const data = { version: '1.5.2', exported: new Date().toISOString(), boards, settings };
/**
* Prueft ob eine URL ein sicheres Protokoll hat.
* Blockiert javascript:, data:, vbscript: etc.
* @param {string} url
* @returns {boolean}
*/
function isSafeUrl(url) {
try {
const u = new URL(url);
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
} catch {
return false;
}
}
/**
* 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)
btnExport.addEventListener('click', async () => {
const widgetData = await Store.get('widgetStates');
const data = {
version: '2.3.0',
exported: new Date().toISOString(),
boards,
settings,
trash,
notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : [],
calculator: widgetData && widgetData.calculator ? widgetData.calculator.history || [] : [],
timerPresets: widgetData && widgetData.timer ? widgetData.timer.presets || [] : []
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -28,34 +64,156 @@ function initDataButtons() {
if (!file) return;
try {
const data = JSON.parse(await file.text());
if (!Array.isArray(data.boards)) throw new Error('Ungültiges Format');
const validBoards = data.boards.filter(b => {
if (!b || typeof b.title !== 'string' || !Array.isArray(b.bookmarks)) return false;
b.id = b.id || uid();
b.blurred = !!b.blurred;
b.bookmarks = b.bookmarks.filter(bm => {
if (!bm || typeof bm.title !== 'string' || typeof bm.url !== 'string') return false;
bm.id = bm.id || uid();
bm.desc = bm.desc || '';
return true;
if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format'));
const validBoards = data.boards
.filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
.map(b => {
const board = {
id: b.id || uid(),
title: String(b.title).slice(0, 100),
blurred: !!b.blurred,
locked: !!b.locked,
bookmarks: b.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)
}))
};
// Freies Layout (LAYOUT-04): valide Position uebernehmen, sonst gridded ensureBoardPositions neu.
const pos = safePos(b.pos);
if (pos) board.pos = pos;
return board;
});
return true;
});
if (validBoards.length === 0) throw new Error('Keine gültigen Boards gefunden');
if (validBoards.length === 0) throw new Error(t('data.no_boards'));
const ok = await HellionDialog.confirm(
`${validBoards.length} Boards importieren? Bestehende Daten bleiben erhalten.`,
{ type: 'info', title: 'JSON Import' }
t('data.import_confirm', { count: validBoards.length }),
{ type: 'info', title: t('data.import_confirm.title') }
);
if (!ok) return;
boards = [...boards, ...validBoards];
await saveBoards();
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)
let notesImported = 0;
const existingWidgets = await Store.get('widgetStates') || {};
if (Array.isArray(data.notes) && data.notes.length > 0) {
const existingNotes = Array.isArray(existingWidgets.notes) ? existingWidgets.notes : [];
const importNotes = data.notes
.filter(n => n && n.id && n.template)
.map(n => ({
id: n.id,
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
title: String(n.title || '').slice(0, 200),
content: String(n.content || '').slice(0, 5000),
x: typeof n.x === 'number' ? n.x : 120,
y: typeof n.y === 'number' ? n.y : 80,
width: typeof n.width === 'number' ? n.width : 280,
height: typeof n.height === 'number' ? n.height : 220,
open: n.open !== false,
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
}));
// Limit beachten
const spaceLeft = Notes.MAX_NOTES - existingNotes.length;
const toImport = importNotes.slice(0, spaceLeft);
if (toImport.length > 0) {
const merged = [...existingNotes, ...toImport];
existingWidgets.notes = merged;
notesImported = toImport.length;
}
}
// Calculator-History importieren (falls vorhanden)
let calcImported = false;
if (Array.isArray(data.calculator) && data.calculator.length > 0) {
const calcHistory = data.calculator.filter(h => h && typeof h.expr === 'string' && typeof h.result === 'string');
if (calcHistory.length > 0) {
if (!existingWidgets.calculator) {
existingWidgets.calculator = { x: 400, y: 120, width: 280, height: 400, open: false, history: [] };
}
existingWidgets.calculator.history = calcHistory.slice(0, Calculator.MAX_HISTORY);
calcImported = true;
}
}
// Timer-Presets importieren (falls vorhanden)
let timerImported = false;
if (Array.isArray(data.timerPresets) && data.timerPresets.length > 0) {
const validPresets = data.timerPresets.filter(p => p && typeof p.name === 'string' && typeof p.seconds === 'number');
if (validPresets.length > 0) {
if (!existingWidgets.timer) {
existingWidgets.timer = { x: 600, y: 80, width: 260, height: 360, open: false, presets: [] };
}
existingWidgets.timer.presets = validPresets.slice(0, Timer.MAX_PRESETS);
timerImported = true;
}
}
// Gemeinsam speichern
await Store.set('widgetStates', existingWidgets);
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
if (notesImported > 0) await Notes.init();
if (calcImported) await Calculator.load();
if (timerImported) await Timer.load();
const noteMsg = notesImported > 0 ? t('data.notes_suffix', { count: notesImported }) : '';
const calcMsg = calcImported ? t('data.calc_suffix') : '';
const timerMsg = timerImported ? t('data.timer_suffix') : '';
await HellionDialog.alert(
`${validBoards.length} Board(s) erfolgreich importiert.`,
{ type: 'success', title: 'Import erfolgreich' }
t('data.import_success', { boards: validBoards.length, notes: noteMsg, calc: calcMsg, timer: timerMsg }),
{ type: 'success', title: t('data.import_success.title') }
);
} catch (err) {
await HellionDialog.alert('Fehler beim Import: ' + err.message, { type: 'danger', title: 'Import fehlgeschlagen' });
await HellionDialog.alert(t('data.import_error', { error: err.message }), { type: 'danger', title: t('data.import_error.title') });
}
e.target.value = '';
});
+35 -9
View File
@@ -40,23 +40,34 @@ const HellionDialog = {
*/
_show(config) {
return new Promise(resolve => {
const prevFocus = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'dialog-overlay';
const box = document.createElement('div');
box.className = 'dialog-box';
box.setAttribute('role', config.isConfirm ? 'alertdialog' : 'dialog');
box.setAttribute('aria-modal', 'true');
// Eindeutige IDs pro Dialog-Instanz: kurz gestapelte Dialoge (timer.js/
// image-ref.js feuern teils ohne await) duerfen sich keine festen IDs
// teilen, sonst liest der Screenreader ueber aria-* den falschen Titel.
const uid = 'dlg-' + Date.now().toString(36) + '-' + (HellionDialog._seq = (HellionDialog._seq || 0) + 1);
box.setAttribute('aria-labelledby', uid + '-title');
box.setAttribute('aria-describedby', uid + '-body');
// Header
const header = document.createElement('div');
header.className = 'dialog-header';
header.appendChild(this._createIcon(config.type));
const titleSpan = document.createElement('span');
titleSpan.id = uid + '-title';
titleSpan.textContent = config.title;
header.appendChild(titleSpan);
// Body
const body = document.createElement('div');
body.className = 'dialog-body';
body.id = uid + '-body';
body.textContent = config.message;
// Actions
@@ -64,9 +75,12 @@ const HellionDialog = {
actions.className = 'dialog-actions';
function cleanup(result) {
overlay.classList.remove('active');
document.removeEventListener('keydown', keyHandler);
setTimeout(() => overlay.remove(), 200);
withViewTransition(() => {
overlay.classList.remove('active');
overlay.remove();
});
if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus();
resolve(result);
}
@@ -104,12 +118,24 @@ const HellionDialog = {
e.preventDefault();
cleanup(config.isConfirm ? false : undefined);
}
if (e.key === 'Tab') {
// Fokus-Falle: nur die Buttons im actions-Container sind fokussierbar
const items = Array.from(actions.querySelectorAll('button'));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
}
}
document.addEventListener('keydown', keyHandler);
document.body.appendChild(overlay);
// Nächster Frame für CSS-Transition
requestAnimationFrame(() => {
// View-Transition uebernimmt das Fade; Fokus bleibt erhalten
withViewTransition(() => {
overlay.classList.add('active');
confirmBtn.focus();
});
@@ -126,8 +152,8 @@ const HellionDialog = {
const opts = options || {};
return this._show({
message,
title: opts.title || 'Hinweis',
confirmText: opts.confirmText || 'OK',
title: opts.title || t('dialog.default_title'),
confirmText: opts.confirmText || t('dialog.ok'),
cancelText: '',
type: opts.type || 'info',
isConfirm: false
@@ -144,9 +170,9 @@ const HellionDialog = {
const opts = options || {};
return this._show({
message,
title: opts.title || 'Bestätigung',
confirmText: opts.confirmText || 'OK',
cancelText: opts.cancelText || 'Abbrechen',
title: opts.title || t('dialog.confirm_title'),
confirmText: opts.confirmText || t('dialog.ok'),
cancelText: opts.cancelText || t('dialog.cancel'),
type: opts.type || 'info',
isConfirm: true
});
+72 -73
View File
@@ -5,95 +5,92 @@
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() {
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 => {
const handle = boardEl.querySelector('.board-drag-handle');
if (!handle) return;
handle.style.cursor = 'grab';
handle.addEventListener('pointerdown', e => {
handle.addEventListener('pointerdown', function onDown(e) {
// Auf Mobil ist .board position:static (Stapel) -> kein Free-Move.
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();
handle.setPointerCapture(e.pointerId);
handle.style.cursor = 'grabbing';
const rect = boardEl.getBoundingClientRect();
// 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);
// .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
// Drag sonst abreissen wuerde). Der Guard prueft genau diese Klasse (Phase-4-Review 2a).
boardEl.classList.add('dragging');
dragging = { el: boardEl, ghost,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top
};
});
const rect = boardEl.getBoundingClientRect();
const offX = e.clientX - rect.left;
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 => {
if (!dragging || dragging.el !== boardEl) return;
e.preventDefault();
dragging.ghost.style.left = (e.clientX - dragging.offsetX) + 'px';
dragging.ghost.style.top = (e.clientY - dragging.offsetY) + 'px';
const target = getInsertTarget(e.clientX, e.clientY);
if (target && target.el !== boardEl) {
target.before
? target.el.parentNode.insertBefore(placeholder, target.el)
: target.el.parentNode.insertBefore(placeholder, target.el.nextSibling);
function onMove(ev) {
if (Math.abs(ev.clientX - startCX) > 3 || Math.abs(ev.clientY - startCY) > 3) moved = true;
const maxX = window.innerWidth - boardEl.offsetWidth;
const maxY = window.innerHeight - boardEl.offsetHeight;
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
boardEl.style.setProperty('--board-x', x + 'px');
boardEl.style.setProperty('--board-y', y + 'px');
}
});
handle.addEventListener('pointerup', async () => {
if (!dragging || dragging.el !== boardEl) return;
handle.style.cursor = 'grab';
placeholder.parentNode.insertBefore(boardEl, placeholder);
placeholder.remove(); placeholder = null;
boardEl.classList.remove('dragging');
dragging.ghost.remove();
dragging = null;
// Gemeinsames Aufraeumen: Pointer-Capture freigeben, ALLE Listener entfernen,
// .board.dragging entfernen. MUSS auch im Cancel-Pfad laufen — sonst klebt die Klasse
// und der app.js-Sync-Guard unterdrueckt dauerhaft Quick-Save-Renders (Phase-5-Review).
function cleanup() {
try { handle.releasePointerCapture(e.pointerId); } catch (_) { /* schon freigegeben */ }
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onCancel);
boardEl.classList.remove('dragging');
}
// Neue Reihenfolge aus DOM ablesen
const newOrder = Array.from(wrapper.querySelectorAll('.board'))
.map(el => el.dataset.boardId).filter(Boolean);
boards.sort((a, b) => newOrder.indexOf(a.id) - newOrder.indexOf(b.id));
await saveBoards();
});
async function onUp() {
cleanup();
// Nur bei echtem Verschieben persistieren — sonst board.pos unangetastet lassen.
if (moved) {
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', () => {
if (!dragging) return;
dragging.ghost.remove();
if (placeholder) { placeholder.remove(); placeholder = null; }
boardEl.classList.remove('dragging');
dragging = null;
handle.style.cursor = 'grab';
// pointercancel feuert STATT pointerup bei Touch-Interrupt, Browser-Geste oder wenn das
// captured Element aus dem DOM faellt -> nur aufraeumen (cleanup), pos NICHT ueberschreiben.
function onCancel() {
cleanup();
if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
}
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onCancel);
});
});
}
@@ -113,6 +110,8 @@ function initBookmarkDragDrop(listEl, board) {
listEl.addEventListener('dragend', e => {
const item = e.target.closest('.bm-item');
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 => {
+1012
View File
File diff suppressed because it is too large Load Diff
+491
View File
@@ -0,0 +1,491 @@
/* =============================================
HELLION NEWTAB — image-ref.js
Bild-Referenz Widget: Session-only Bildanzeige
mit Canvas API WebP-Konvertierung
============================================= */
const ImageRef = {
MAX_IMAGES: 3,
STORAGE_KEY: 'widgetStates',
SESSION_KEY: 'imageRefData',
/** @type {Array<{id: string, label: string, x: number, y: number, width: number, height: number, open: boolean}>} */
_images: [],
_saveTimer: null,
// ---- STORAGE (persistent: Position/Meta) ----
/**
* Widget-Meta aus persistentem Storage laden
*/
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.imageRef && Array.isArray(data.imageRef.images)) {
this._images = data.imageRef.images;
}
},
/**
* Widget-Meta persistent speichern
* Bestehende Notes, Calculator, Timer bleiben erhalten
*/
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
if (data.notes === undefined) data.notes = [];
// Positionen aus WidgetManager aktualisieren
const updated = this._images.map(img => {
const ws = WidgetManager.getState(img.id);
if (ws) {
img.x = ws.x;
img.y = ws.y;
img.width = ws.width;
img.height = ws.height;
img.open = ws.open;
}
return img;
});
data.imageRef = { images: updated };
await Store.set(this.STORAGE_KEY, data);
},
/**
* Debounced Save
*/
_debouncedSave() {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => this.save(), 500);
},
// ---- SESSION STORAGE (Bilddaten) ----
/**
* Bilddaten in sessionStorage speichern
*/
_saveSession() {
try {
const sessionData = {};
this._images.forEach(img => {
const dataUrl = this._getSessionImage(img.id);
if (dataUrl) sessionData[img.id] = dataUrl;
});
sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(sessionData));
} catch (e) {
console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e);
}
},
/**
* Bilddaten aus sessionStorage laden
* @returns {Object} - { id: dataUrl, ... }
*/
_loadSessionAll() {
try {
const raw = sessionStorage.getItem(this.SESSION_KEY);
if (!raw) return {};
return JSON.parse(raw);
} catch (e) {
console.warn('ImageRef: sessionStorage Read fehlgeschlagen', e);
return {};
}
},
/**
* Einzelnes Bild aus sessionStorage lesen
* @param {string} id
* @returns {string|null}
*/
_getSessionImage(id) {
const all = this._loadSessionAll();
return all[id] || null;
},
/**
* Einzelnes Bild in sessionStorage setzen
* @param {string} id
* @param {string} dataUrl
*/
_setSessionImage(id, dataUrl) {
try {
const all = this._loadSessionAll();
all[id] = dataUrl;
sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(all));
} catch (e) {
console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e);
HellionDialog.alert(
t('imageref.storage_error'),
{ type: 'danger', title: t('imageref.storage_error.title') }
);
}
},
/**
* Einzelnes Bild aus sessionStorage entfernen
* @param {string} id
*/
_removeSessionImage(id) {
try {
const all = this._loadSessionAll();
delete all[id];
sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(all));
} catch (e) {
console.warn('ImageRef: sessionStorage Remove fehlgeschlagen', e);
}
},
// ---- WIDGET LIFECYCLE ----
/**
* Neues Bild-Widget erstellen (oeffnet File-Dialog)
*/
async create() {
if (!settings.imageRefEnabled) return;
if (this._images.length >= this.MAX_IMAGES) {
await HellionDialog.alert(
t('imageref.limit', { max: this.MAX_IMAGES }),
{ type: 'warning', title: t('imageref.limit.title') }
);
return;
}
// Freie ID finden
const usedIds = new Set(this._images.map(i => i.id));
let slotId = null;
for (let i = 0; i < this.MAX_IMAGES; i++) {
const candidate = 'image_' + i;
if (!usedIds.has(candidate)) {
slotId = candidate;
break;
}
}
if (!slotId) return;
// File-Dialog
const file = await this._pickFile();
if (!file) return;
// Bild verarbeiten
let dataUrl;
try {
dataUrl = await this._processFile(file);
} catch (err) {
await HellionDialog.alert(
t('imageref.load_error', { error: err.message }),
{ type: 'danger', title: t('imageref.load_error.title') }
);
return;
}
// In sessionStorage speichern
this._setSessionImage(slotId, dataUrl);
// Meta erstellen
const imageData = {
id: slotId,
label: '',
x: 200 + (this._images.length * 40),
y: 120 + (this._images.length * 30),
width: 320,
height: 280,
open: true
};
this._images.push(imageData);
// Widget erstellen
this._createWidget(imageData, dataUrl);
await this.save();
},
/**
* Widget im DOM erstellen
* @param {Object} imageData
* @param {string|null} dataUrl
*/
_createWidget(imageData, dataUrl) {
WidgetManager.create('image', {
id: imageData.id,
title: imageData.label || t('imageref.title'),
x: imageData.x,
y: imageData.y,
width: imageData.width,
height: imageData.height,
open: imageData.open !== false
});
const body = WidgetManager.getBody(imageData.id);
if (body) this.renderBody(imageData, body, dataUrl);
},
/**
* Widget geschlossen — Daten aufraeumen
* @param {string} id
*/
async onClose(id) {
this._removeSessionImage(id);
this._images = this._images.filter(img => img.id !== id);
await this.save();
},
// ---- UI RENDERING ----
/**
* Widget-Body rendern
* @param {Object} imageData
* @param {HTMLElement} bodyEl
* @param {string|null} dataUrl
*/
renderBody(imageData, bodyEl, dataUrl) {
bodyEl.textContent = '';
const container = document.createElement('div');
container.className = 'imgref-container';
if (dataUrl) {
// Bild anzeigen
const wrapper = document.createElement('div');
wrapper.className = 'imgref-img-wrapper';
const img = document.createElement('img');
img.className = 'imgref-img';
img.src = dataUrl;
img.alt = imageData.label || t('imageref.title');
wrapper.appendChild(img);
// Bild ersetzen Button
const replaceBtn = document.createElement('button');
replaceBtn.className = 'imgref-replace-btn';
replaceBtn.type = 'button';
replaceBtn.textContent = t('imageref.replace');
replaceBtn.addEventListener('click', async () => {
const file = await this._pickFile();
if (!file) return;
try {
const newDataUrl = await this._processFile(file);
this._setSessionImage(imageData.id, newDataUrl);
this.renderBody(imageData, bodyEl, newDataUrl);
} catch (err) {
await HellionDialog.alert(
t('imageref.load_error', { error: err.message }),
{ type: 'danger', title: t('imageref.load_error.title') }
);
}
});
container.append(wrapper, replaceBtn);
} else {
// Drop-Zone (kein Bild vorhanden)
const dropzone = this._createDropzone(imageData, bodyEl);
container.appendChild(dropzone);
}
// Label-Input
const label = document.createElement('input');
label.className = 'imgref-label';
label.type = 'text';
label.placeholder = t('imageref.label_placeholder');
label.maxLength = 100;
label.value = imageData.label || '';
label.addEventListener('input', () => {
const text = label.value.trim().slice(0, 100);
imageData.label = text;
// Widget-Titel aktualisieren
const entry = WidgetManager._widgets.get(imageData.id);
if (entry) {
const titleEl = entry.el.querySelector('.widget-title');
if (titleEl) titleEl.textContent = text || t('imageref.title');
entry.state.title = text || t('imageref.title');
}
this._debouncedSave();
});
container.appendChild(label);
bodyEl.appendChild(container);
},
/**
* Drop-Zone erstellen (fuer leere Widgets / neue Bilder)
* @param {Object} imageData
* @param {HTMLElement} bodyEl
* @returns {HTMLElement}
*/
_createDropzone(imageData, bodyEl) {
const dropzone = document.createElement('div');
dropzone.className = 'imgref-dropzone';
const icon = document.createElement('div');
icon.className = 'imgref-dropzone-icon';
icon.textContent = '\uD83D\uDDBC\uFE0F';
const text = document.createElement('span');
text.textContent = t('imageref.dropzone');
dropzone.append(icon, text);
// Klick -> File-Dialog
dropzone.addEventListener('click', async () => {
const file = await this._pickFile();
if (!file) return;
try {
const dataUrl = await this._processFile(file);
this._setSessionImage(imageData.id, dataUrl);
this.renderBody(imageData, bodyEl, dataUrl);
await this.save();
} catch (err) {
await HellionDialog.alert(
t('imageref.load_error', { error: err.message }),
{ type: 'danger', title: t('imageref.load_error.title') }
);
}
});
// Drag & Drop
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
});
dropzone.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (!file || !file.type.startsWith('image/')) {
await HellionDialog.alert(
t('imageref.invalid_file'),
{ type: 'warning', title: t('imageref.invalid_file.title') }
);
return;
}
try {
const dataUrl = await this._processFile(file);
this._setSessionImage(imageData.id, dataUrl);
this.renderBody(imageData, bodyEl, dataUrl);
await this.save();
} catch (err) {
await HellionDialog.alert(
t('imageref.load_error', { error: err.message }),
{ type: 'danger', title: t('imageref.load_error.title') }
);
}
});
return dropzone;
},
// ---- FILE HANDLING ----
/**
* File-Dialog oeffnen
* @returns {Promise<File|null>}
*/
_pickFile() {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.addEventListener('change', () => {
resolve(input.files[0] || null);
});
// Cancel erkennen
input.addEventListener('cancel', () => resolve(null));
input.click();
});
},
/**
* Bild per Canvas API zu WebP konvertieren
* @param {File} file
* @returns {Promise<string>} WebP DataURL
*/
_processFile(file) {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const webpUrl = canvas.toDataURL('image/webp', 0.85);
URL.revokeObjectURL(objectUrl);
resolve(webpUrl);
} catch (err) {
URL.revokeObjectURL(objectUrl);
reject(err);
}
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error(t('imageref.load_error', { error: 'unknown' })));
};
img.src = objectUrl;
});
},
// ---- INIT ----
/**
* ImageRef initialisieren (aus app.js aufgerufen)
*/
async init() {
await this.load();
// Widgets wiederherstellen (nur wenn Feature aktiviert)
if (settings.imageRefEnabled && this._images.length > 0) {
const sessionData = this._loadSessionAll();
this._images.forEach(imageData => {
if (imageData.open !== false) {
const dataUrl = sessionData[imageData.id] || null;
this._createWidget(imageData, dataUrl);
}
});
}
// Widget-Lifecycle-Events
const self = this;
WidgetManager.on('widget:close', (e) => {
const isImage = self._images.some(img => img.id === e.detail.id);
if (isImage) {
self.onClose(e.detail.id);
}
});
WidgetManager.on('widget:minimize', (e) => {
const isImage = self._images.some(img => img.id === e.detail.id);
if (isImage) {
self.save();
}
});
WidgetManager.on('widget:open', (e) => {
const imgData = self._images.find(img => img.id === e.detail.id);
if (imgData) {
const body = WidgetManager.getBody(e.detail.id);
if (body && body.children.length === 0) {
const dataUrl = self._getSessionImage(e.detail.id);
self.renderBody(imgData, body, dataUrl);
}
self.save();
}
});
}
};
+573
View File
@@ -0,0 +1,573 @@
/* =============================================
HELLION NEWTAB — notes.js
Notes: Freitext, Checklisten, Notebook-Sidebar
============================================= */
const Notes = {
MAX_NOTES: 5,
MAX_CHARS: 2500,
STORAGE_KEY: 'widgetStates',
/** @type {Array<Object>} */
_notes: [],
_saveTimer: null,
/**
* Notes aus Storage laden
* @returns {Promise<Array>}
*/
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && Array.isArray(data.notes)) {
this._notes = data.notes;
}
return this._notes;
},
/**
* Alle Notes in Storage speichern
*/
async save() {
// Widget-States mit Note-Daten mergen
const widgetStates = WidgetManager.save ? await WidgetManager.save() : [];
// Note-Daten mit aktuellen Widget-Positionen mergen
const merged = this._notes.map(note => {
const ws = widgetStates.find(w => w.id === note.id);
if (ws) {
note.x = ws.x;
note.y = ws.y;
note.width = ws.width;
note.height = ws.height;
note.open = ws.open;
note.title = ws.title;
}
return note;
});
// Calculator- und Timer-State beibehalten falls vorhanden
const existing = await Store.get(this.STORAGE_KEY);
const saveData = { notes: merged };
if (existing && existing.calculator) {
saveData.calculator = existing.calculator;
}
if (existing && existing.timer) {
saveData.timer = existing.timer;
}
if (existing && existing.imageRef) {
saveData.imageRef = existing.imageRef;
}
await Store.set(this.STORAGE_KEY, saveData);
},
/**
* Debounced Save (fuer Auto-Save bei Input)
*/
_debouncedSave() {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => this.save(), 500);
},
/**
* Neue Note erstellen
* @param {'text'|'checklist'} template
* @returns {Promise<string|null>} widget-id oder null bei vollem Limit
*/
async create(template) {
if (this._notes.length >= this.MAX_NOTES) {
await HellionDialog.alert(
t('notes.limit_message', { max: this.MAX_NOTES }),
{ type: 'warning', title: t('notes.limit_title') }
);
return null;
}
const noteData = {
id: 'note_' + uid(),
title: template === 'checklist' ? t('notes.checklist_title') : t('notes.default_title'),
content: '',
template: template,
x: 120 + (this._notes.length * 30),
y: 80 + (this._notes.length * 30),
width: 280,
height: 220,
open: true,
checkedItems: [],
checklistItems: []
};
this._notes.push(noteData);
// Widget erstellen
const widgetId = WidgetManager.create('note', {
id: noteData.id,
title: noteData.title,
x: noteData.x,
y: noteData.y,
width: noteData.width,
height: noteData.height,
open: true
});
// Body rendern
const body = WidgetManager.getBody(widgetId);
if (body) this.renderBody(noteData, body);
await this.save();
return widgetId;
},
/**
* Note-Body rendern (in Widget-Body einfuegen)
* @param {Object} noteData
* @param {HTMLElement} bodyEl
*/
renderBody(noteData, bodyEl) {
bodyEl.textContent = '';
if (noteData.template === 'checklist') {
this._renderChecklistBody(noteData, bodyEl);
} else {
this._renderTextBody(noteData, bodyEl);
}
},
/**
* Freitext-Body: Textarea mit Zeichenzaehler
* @param {Object} noteData
* @param {HTMLElement} bodyEl
*/
_renderTextBody(noteData, bodyEl) {
const textarea = document.createElement('textarea');
textarea.className = 'widget-textarea';
textarea.placeholder = t('notes.placeholder');
textarea.spellcheck = false;
textarea.value = noteData.content || '';
textarea.maxLength = this.MAX_CHARS;
const counter = document.createElement('span');
counter.className = 'widget-char-count';
counter.textContent = (noteData.content || '').length + ' / ' + this.MAX_CHARS;
textarea.addEventListener('input', () => {
noteData.content = textarea.value;
const len = textarea.value.length;
counter.textContent = len + ' / ' + this.MAX_CHARS;
counter.classList.toggle('limit', len >= this.MAX_CHARS);
// Auto-Titel aus erster Zeile
const firstLine = textarea.value.split('\n')[0].trim().slice(0, 20);
if (firstLine) {
noteData.title = firstLine;
const widgetEntry = WidgetManager._widgets.get(noteData.id);
if (widgetEntry) {
const titleEl = widgetEntry.el.querySelector('.widget-title');
if (titleEl && titleEl.contentEditable !== 'true') {
titleEl.textContent = firstLine;
}
widgetEntry.state.title = firstLine;
}
}
this._debouncedSave();
});
bodyEl.append(textarea, counter);
},
/**
* Checklisten-Body: Items mit Checkboxen
* @param {Object} noteData
* @param {HTMLElement} bodyEl
*/
_renderChecklistBody(noteData, bodyEl) {
const list = document.createElement('ul');
list.className = 'widget-checklist';
// Bestehende Items rendern
if (!Array.isArray(noteData.checklistItems)) {
noteData.checklistItems = [];
}
const renderItems = () => {
list.textContent = '';
noteData.checklistItems.forEach((item, idx) => {
const li = this._createChecklistItem(noteData, item, idx, renderItems);
list.appendChild(li);
});
};
renderItems();
// Eingabefeld fuer neue Items
const addRow = document.createElement('div');
addRow.className = 'checklist-add';
const addInput = document.createElement('input');
addInput.className = 'checklist-add-input';
addInput.type = 'text';
addInput.placeholder = t('notes.checklist_placeholder');
addInput.maxLength = 100;
addInput.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
const text = addInput.value.trim();
if (!text) return;
noteData.checklistItems.push({ text, checked: false });
addInput.value = '';
renderItems();
this._updateChecklistContent(noteData);
await this.save();
}
});
addRow.appendChild(addInput);
bodyEl.append(list, addRow);
},
/**
* Einzelnes Checklisten-Item erstellen
* @param {Object} noteData
* @param {Object} item - { text, checked }
* @param {number} idx
* @param {Function} rerenderFn
* @returns {HTMLElement}
*/
_createChecklistItem(noteData, item, idx, rerenderFn) {
const li = document.createElement('li');
li.className = 'checklist-item' + (item.checked ? ' checked' : '');
const checkbox = document.createElement('span');
checkbox.className = 'checklist-checkbox';
checkbox.textContent = item.checked ? '\u2713' : '';
checkbox.addEventListener('click', async () => {
item.checked = !item.checked;
li.classList.toggle('checked', item.checked);
checkbox.textContent = item.checked ? '\u2713' : '';
this._updateChecklistContent(noteData);
await this.save();
});
const text = document.createElement('span');
text.className = 'checklist-text';
text.textContent = item.text;
const removeBtn = document.createElement('button');
removeBtn.className = 'checklist-remove';
removeBtn.textContent = '\u2715';
removeBtn.addEventListener('click', async () => {
noteData.checklistItems.splice(idx, 1);
rerenderFn();
this._updateChecklistContent(noteData);
await this.save();
});
li.append(checkbox, text, removeBtn);
return li;
},
/**
* Checklisten-Content fuer Export/Vorschau aktualisieren
* @param {Object} noteData
*/
_updateChecklistContent(noteData) {
const total = noteData.checklistItems.length;
const done = noteData.checklistItems.filter(i => i.checked).length;
noteData.content = noteData.checklistItems.map(i => (i.checked ? '[x] ' : '[ ] ') + i.text).join('\n');
// Auto-Titel: "X/Y erledigt" falls kein manueller Titel
const widgetEntry = WidgetManager._widgets.get(noteData.id);
if (widgetEntry) {
const defaultTitle = t('notes.checklist_progress', { done: done, total: total });
const titleEl = widgetEntry.el.querySelector('.widget-title');
if (titleEl && titleEl.contentEditable !== 'true') {
// Nur wenn Titel noch Standard ist
if (noteData.title === t('notes.checklist_title') || /^\d+\/\d+\s/.test(noteData.title)) {
noteData.title = defaultTitle;
titleEl.textContent = defaultTitle;
widgetEntry.state.title = defaultTitle;
}
}
}
},
/**
* Note anhand ID finden
* @param {string} id
* @returns {Object|null}
*/
getNote(id) {
return this._notes.find(n => n.id === id) || null;
},
/**
* Note loeschen
* @param {string} id
*/
async deleteNote(id) {
const idx = this._notes.findIndex(n => n.id === id);
if (idx === -1) return;
const ok = await HellionDialog.confirm(
t('notes.delete_confirm'),
{ type: 'danger', title: t('notes.delete_title'), confirmText: t('notes.delete_button') }
);
if (!ok) return;
this._notes.splice(idx, 1);
WidgetManager.close(id);
await this.save();
},
/**
* Note als .md exportieren
* @param {Object} noteData
*/
exportNote(noteData) {
let md = '# ' + noteData.title + '\n\n';
if (noteData.template === 'checklist') {
noteData.checklistItems.forEach(item => {
md += (item.checked ? '- [x] ' : '- [ ] ') + item.text + '\n';
});
} else {
md += noteData.content || '';
}
md += '\n\n---\n*' + t('notes.export_footer') + '*\n';
const blob = new Blob([md], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (noteData.title || 'note').replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_') + '.md';
a.click();
URL.revokeObjectURL(url);
},
// ---- NOTEBOOK SIDEBAR ----
/**
* Notebook-Sidebar oeffnen
*/
openNotebook() {
const overlay = document.getElementById('notebookOverlay');
const panel = document.getElementById('notebookPanel');
if (overlay) overlay.classList.add('active');
if (panel) panel.classList.add('open');
this._renderNotebookSlots();
},
/**
* Notebook-Sidebar schliessen
*/
closeNotebook() {
const overlay = document.getElementById('notebookOverlay');
const panel = document.getElementById('notebookPanel');
if (overlay) overlay.classList.remove('active');
if (panel) panel.classList.remove('open');
},
/**
* Notebook-Slots rendern
*/
_renderNotebookSlots() {
const container = document.getElementById('notebookSlots');
const countEl = document.getElementById('notebookCount');
if (!container) return;
container.textContent = '';
if (countEl) countEl.textContent = this._notes.length + ' / ' + this.MAX_NOTES;
// Belegte Slots
this._notes.forEach(note => {
const slot = this._createNotebookSlot(note);
container.appendChild(slot);
});
// Leere Slots
const remaining = this.MAX_NOTES - this._notes.length;
for (let i = 0; i < remaining; i++) {
const emptySlot = this._createEmptySlot();
container.appendChild(emptySlot);
}
},
/**
* Belegten Notebook-Slot erstellen
* @param {Object} note
* @returns {HTMLElement}
*/
_createNotebookSlot(note) {
const slot = document.createElement('div');
slot.className = 'notebook-slot';
// Header
const header = document.createElement('div');
header.className = 'notebook-slot-header';
const title = document.createElement('span');
title.className = 'notebook-slot-title';
const typeIcon = document.createElement('span');
typeIcon.className = 'notebook-slot-type';
typeIcon.textContent = note.template === 'checklist' ? '\u2611' : '\u270E';
title.append(typeIcon);
title.append(document.createTextNode(' ' + note.title));
header.appendChild(title);
// Preview
const preview = document.createElement('div');
preview.className = 'notebook-slot-preview';
if (note.template === 'checklist') {
const total = note.checklistItems ? note.checklistItems.length : 0;
const done = note.checklistItems ? note.checklistItems.filter(i => i.checked).length : 0;
preview.textContent = t('notes.checklist_progress', { done: done, total: total });
} else {
preview.textContent = (note.content || '').slice(0, 50) || t('notes.empty_preview');
}
// Actions
const actions = document.createElement('div');
actions.className = 'notebook-slot-actions';
const btnExport = document.createElement('button');
btnExport.className = 'notebook-slot-btn';
btnExport.textContent = t('notes.export');
btnExport.addEventListener('click', (e) => {
e.stopPropagation();
this.exportNote(note);
});
const btnDelete = document.createElement('button');
btnDelete.className = 'notebook-slot-btn danger';
btnDelete.textContent = '\uD83D\uDDD1';
btnDelete.addEventListener('click', async (e) => {
e.stopPropagation();
await this.deleteNote(note.id);
this._renderNotebookSlots();
});
actions.append(btnExport, btnDelete);
slot.append(header, preview, actions);
// Klick oeffnet Note als Widget
slot.addEventListener('click', async () => {
if (WidgetManager.isOpen(note.id)) {
WidgetManager.bringToFront(note.id);
} else {
await WidgetManager.openWidget(note.id);
}
this.closeNotebook();
});
return slot;
},
/**
* Leeren Notebook-Slot erstellen
* @returns {HTMLElement}
*/
_createEmptySlot() {
const slot = document.createElement('div');
slot.className = 'notebook-slot-empty';
const label = document.createElement('span');
label.textContent = t('notes.create');
slot.appendChild(label);
// Klick zeigt Typ-Auswahl
let chooserOpen = false;
slot.addEventListener('click', () => {
if (chooserOpen) return;
chooserOpen = true;
label.style.display = 'none';
const chooser = document.createElement('div');
chooser.className = 'notebook-type-chooser';
const btnText = document.createElement('button');
btnText.className = 'notebook-type-btn';
btnText.textContent = t('notes.text_type');
btnText.addEventListener('click', async (e) => {
e.stopPropagation();
await this.create('text');
this._renderNotebookSlots();
});
const btnCheck = document.createElement('button');
btnCheck.className = 'notebook-type-btn';
btnCheck.textContent = t('notes.checklist_type');
btnCheck.addEventListener('click', async (e) => {
e.stopPropagation();
await this.create('checklist');
this._renderNotebookSlots();
});
chooser.append(btnText, btnCheck);
slot.appendChild(chooser);
});
return slot;
},
// ---- TOOLBAR EVENTS ----
/**
* Widget-Toolbar initialisieren
*/
initToolbar() {
const toolbar = document.getElementById('widgetToolbar');
if (!toolbar) return;
toolbar.addEventListener('click', async (e) => {
const btn = e.target.closest('.widget-toolbar-btn');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'new-note') {
await this.create('text');
} else if (action === 'new-checklist') {
await this.create('checklist');
} else if (action === 'calculator') {
Calculator.toggle();
} else if (action === 'timer') {
Timer.toggle();
} else if (action === 'image-ref') {
ImageRef.create();
} else if (action === 'notebook') {
this.openNotebook();
}
});
},
// ---- INIT ----
/**
* Notes-System initialisieren (ersetzt initStickyNote)
*/
async init() {
await this.load();
// Widgets wiederherstellen
await WidgetManager.restore((noteData, bodyEl) => {
this.renderBody(noteData, bodyEl);
});
// Toolbar initialisieren
this.initToolbar();
// Notebook-Sidebar Events
const notebookOverlay = document.getElementById('notebookOverlay');
if (notebookOverlay) {
notebookOverlay.addEventListener('click', () => this.closeNotebook());
}
const btnCloseNotebook = document.getElementById('btnCloseNotebook');
if (btnCloseNotebook) {
btnCloseNotebook.addEventListener('click', () => this.closeNotebook());
}
// Header btnNote oeffnet Notebook
const btnNote = document.getElementById('btnNote');
if (btnNote) {
btnNote.addEventListener('click', () => this.openNotebook());
}
}
};
+84 -34
View File
@@ -9,43 +9,45 @@ const Onboarding = {
slides: [
{
hero: '\u2B21',
title: 'Willkommen bei Hellion Dashboard',
text: 'Dein neuer Browser-Startbildschirm. Minimalistisch, schnell und vollst\u00E4ndig lokal \u2014 keine Cloud, kein Account, keine Datensammlung.'
titleKey: 'onboarding.s1.title',
textKey: 'onboarding.s1.text'
},
{
hero: '\uD83D\uDCCB',
title: 'Boards & Bookmarks',
features: [
'Erstelle Boards mit dem \u201E+ Board\u201C Button oben',
'Importiere Browser-Lesezeichen \u00FCber den \u201EImport\u201C Button im Header',
'Drag & Drop zum Umsortieren von Boards und Links',
'Blur-Modus f\u00FCr private Boards (\uD83D\uDD12 Icon)'
]
titleKey: 'onboarding.s2.title',
featureKeys: ['onboarding.s2.f1', 'onboarding.s2.f2', 'onboarding.s2.f3', 'onboarding.s2.f4']
},
{
hero: '\uD83C\uDFA8',
title: '8 handgefertigte Themes',
text: 'Klicke auf den „Theme" Button im Header um dein Theme zu wählen. Jedes hat seinen eigenen Stil und Farbpalette.',
titleKey: 'onboarding.s3.title',
textKey: 'onboarding.s3.text',
showThemes: true
},
{
hero: '\u26A1',
title: 'Weitere Features',
features: [
'Suchleiste mit Google, DuckDuckGo oder Bing',
'Sticky Notes f\u00FCr schnelle Notizen',
'Funktioniert komplett offline \u2014 alles lokal gespeichert'
]
hero: '\uD83E\uDDF0',
titleKey: 'onboarding.s4.title',
featureKeys: ['onboarding.s4.f1', 'onboarding.s4.f2', 'onboarding.s4.f3', 'onboarding.s4.f4', 'onboarding.s4.f5', 'onboarding.s4.f6']
},
{
hero: '\uD83D\uDEE1\uFE0F',
title: 'Backups nicht vergessen!',
text: 'Deine Daten sind lokal im Browser gespeichert. Wenn du Browserdaten l\u00F6schst, gehen sie verloren! Sichere regelm\u00E4\u00DFig \u00FCber Settings \u2192 Data \u2192 Export. Wir erinnern dich alle 7 Tage daran.'
titleKey: 'onboarding.s5.title',
textKey: 'onboarding.s5.text'
},
{
hero: '\uD83C\uDFAE',
titleKey: 'onboarding.s6.title',
textKey: 'onboarding.s6.text',
interactive: 'gaming-board'
},
{
hero: '\uD83D\uDD0D',
titleKey: 'onboarding.palette.title',
textKey: 'onboarding.palette.text'
},
{
hero: '\uD83D\uDE80',
title: 'Bereit!',
text: 'Klicke auf \u201E+ Board\u201C um dein erstes Board zu erstellen, oder nutze den \u201EImport\u201C Button im Header um deine Browser-Lesezeichen zu importieren.'
titleKey: 'onboarding.s7.title',
textKey: 'onboarding.s7.text'
}
],
@@ -78,7 +80,7 @@ const Onboarding = {
if (!isLast) {
const skip = document.createElement('button');
skip.className = 'onboarding-skip';
skip.textContent = '\u00DCberspringen';
skip.textContent = t('onboarding.skip');
skip.addEventListener('click', () => this._finish());
modal.appendChild(skip);
}
@@ -94,22 +96,22 @@ const Onboarding = {
const title = document.createElement('div');
title.className = 'onboarding-title';
title.textContent = slide.title;
title.textContent = t(slide.titleKey);
slideEl.appendChild(title);
if (slide.text) {
if (slide.textKey) {
const text = document.createElement('div');
text.className = 'onboarding-text';
text.textContent = slide.text;
text.textContent = t(slide.textKey);
slideEl.appendChild(text);
}
if (slide.features) {
if (slide.featureKeys) {
const list = document.createElement('ul');
list.className = 'onboarding-feature-list';
slide.features.forEach(f => {
slide.featureKeys.forEach(key => {
const li = document.createElement('li');
li.textContent = f;
li.textContent = t(key);
list.appendChild(li);
});
slideEl.appendChild(list);
@@ -118,7 +120,7 @@ const Onboarding = {
if (slide.showThemes) {
const grid = document.createElement('div');
grid.className = 'onboarding-theme-grid';
const themeNames = ['Nebula', 'Crescent', 'Event Horizon', 'Merchantman', 'Julia & Jin', 'SC Sunset', 'Hellion HUD', 'Hellion Energy'];
const themeNames = ['Nebula', 'Crescent', 'Event Horizon', 'Merchantman', 'Julia & Jin', 'SC Sunset', 'Hellion HUD', 'Hellion Energy', 'Satisfactory', 'Avorion', 'Hellion Stealth'];
themeNames.forEach(name => {
const chip = document.createElement('div');
chip.className = 'onboarding-theme-chip';
@@ -151,7 +153,7 @@ const Onboarding = {
if (this.currentSlide > 0) {
const backBtn = document.createElement('button');
backBtn.className = 'btn-secondary';
backBtn.textContent = 'Zur\u00FCck';
backBtn.textContent = t('onboarding.back');
backBtn.addEventListener('click', () => {
this.currentSlide--;
this._render();
@@ -159,16 +161,36 @@ const Onboarding = {
nav.appendChild(backBtn);
}
if (isLast) {
if (slide.interactive === 'gaming-board') {
// Interaktive Slide: Zwei Buttons statt "Weiter"
const noBtn = document.createElement('button');
noBtn.className = 'btn-secondary';
noBtn.textContent = t('onboarding.no');
noBtn.addEventListener('click', () => {
this.currentSlide++;
this._render();
});
const yesBtn = document.createElement('button');
yesBtn.className = 'btn-primary';
yesBtn.textContent = t('onboarding.yes');
yesBtn.addEventListener('click', async () => {
await this._createGamingBoard();
this.currentSlide++;
this._render();
});
nav.append(noBtn, yesBtn);
} else if (isLast) {
const startBtn = document.createElement('button');
startBtn.className = 'btn-primary';
startBtn.textContent = 'Los geht\u2019s!';
startBtn.textContent = t('onboarding.start');
startBtn.addEventListener('click', () => this._finish());
nav.appendChild(startBtn);
} else {
const nextBtn = document.createElement('button');
nextBtn.className = 'btn-primary';
nextBtn.textContent = 'Weiter';
nextBtn.textContent = t('onboarding.next');
nextBtn.addEventListener('click', () => {
this.currentSlide++;
this._render();
@@ -180,6 +202,34 @@ const Onboarding = {
modal.appendChild(footer);
},
/**
* Gaming Starter Board erstellen
* Vorbefuelltes Board mit Community-Links fuer Factory/Space Games
*/
async _createGamingBoard() {
const gamingBoard = {
id: uid(),
title: '\uD83C\uDFAE Gaming',
bookmarks: [
{ id: uid(), title: 'Satisfactory Wiki', url: 'https://satisfactory.wiki.gg', desc: '' },
{ id: uid(), title: 'Satisfactory Calculator', url: 'https://satisfactorytools.com', desc: '' },
{ id: uid(), title: 'Factorio Wiki', url: 'https://wiki.factorio.com', desc: '' },
{ id: uid(), title: 'Factorio Cheatsheet', url: 'https://factoriocheatsheet.com', desc: '' },
{ id: uid(), title: 'Avorion Wiki', url: 'https://wiki.avorion.net', desc: '' },
{ id: uid(), title: 'Minecraft Wiki', url: 'https://minecraft.wiki', desc: '' },
{ id: uid(), title: 'Modrinth (Mods)', url: 'https://modrinth.com', desc: '' },
{ id: uid(), title: 'Star Citizen Wiki', url: 'https://starcitizen.tools', desc: '' },
{ id: uid(), title: 'UEX Corp (Trading)', url: 'https://uexcorp.space', desc: '' },
{ id: uid(), title: 'Hellion TradeCenter', url: 'https://hellion-initiative.online/tradecenter', desc: t('onboarding.tradecenter_desc') }
],
blurred: false
};
boards.push(gamingBoard);
await saveBoards();
renderBoards();
},
/** Keyboard-Navigation */
_bindKeyboard() {
this._keyHandler = (e) => {
+26 -11
View File
@@ -1,8 +1,12 @@
# ⬡ Opera GX — New-Tab Workaround
Opera GX priorisiert die eigene Speed Dial Seite und ignoriert `chrome_url_overrides`
für entpackte Erweiterungen. Um das Hellion Dashboard trotzdem als New-Tab-Seite
zu etablieren, kommen zwei zusätzliche Skripte zum Einsatz.
Opera GX ist der einzige Browser in diesem Projekt der sich aktiv dagegen wehrt,
eine eigene New-Tab-Seite zu nutzen. Während Chrome, Edge, Firefox und selbst Vivaldi
einfach `chrome_url_overrides` respektieren, priorisiert Opera GX seine eigene
Speed Dial Seite und ignoriert den Standard-Override für entpackte Erweiterungen.
Das Ergebnis: vier Stunden Debugging, zwei Workaround-Skripte und ein Reddit-Thread
der tatsächlich geholfen hat. Hier ist was dabei rausgekommen ist.
---
@@ -11,8 +15,8 @@ zu etablieren, kommen zwei zusätzliche Skripte zum Einsatz.
| Browser | New-Tab Override | Zusatzaufwand |
|---|---|---|
| Chrome / Edge / Brave / Vivaldi | `chrome_url_overrides` | Keiner |
| Firefox | `chrome_url_overrides` (MV2) | Eigenes Manifest |
| Opera / Opera GX | Blockiert durch Speed Dial | Workaround nötig |
| Firefox | `chrome_url_overrides` (MV3) | Eigenes Manifest |
| Opera / Opera GX | Blockiert durch Speed Dial | Dieser Ordner hier |
---
@@ -20,25 +24,36 @@ zu etablieren, kommen zwei zusätzliche Skripte zum Einsatz.
### `background.js` — Tab-Management
Überwacht Tab-Aktivitäten im Hintergrund und greift ein bevor Opera seine Startseite lädt.
Überwacht Tab-Aktivitäten im Hintergrund und greift ein bevor Opera seine Startseite laden kann.
- Erkennt `opera://startpage/` und `chrome://startpage/`
- Leitet per `chrome.tabs.update` auf `newtab.html` um
- Prüft zusätzlich bei `onActivated` — auch im Hintergrund geladene Tabs werden sofort aktualisiert
- Prüft zusätzlich bei `onActivated`, weil Opera manche Tabs im Hintergrund lädt
und der erste Redirect dann nicht greift
### `redirect.js` — In-Page Redirect
Einige Opera-Systemprozesse sind so isoliert dass ein externer Eingriff nicht zuverlässig greift.
Manche Opera-Systemprozesse sind so weit isoliert dass ein externer Eingriff
nicht zuverlässig ankommt. Also nochmal von innen.
- Wird als Content Script in Opera-Startseiten-Bereiche injiziert
- Löst den Redirect bei `document_start` aus — minimale Verzögerung, kein Flackern
- Wird als Content Script direkt in Opera-Startseiten-Bereiche injiziert
- Löst den Redirect bei `document_start` aus, bevor die Speed Dial Seite
überhaupt anfangen kann sich aufzubauen
Ja, es braucht wirklich beide Skripte. Opera GX hat das so entschieden.
Das Gute daran: die GitHub Actions kümmern sich darum dass jeder Browser nur bekommt
was er braucht. Das Opera-ZIP enthält die Workaround-Skripte, das Chrome-ZIP nicht.
Wer sich das manuell zusammensuchen müsste wäre vermutlich genauso genervt wie ich
beim Debuggen war.
---
## Datenschutz
Kein Tracking, keine Speicherung, keine externen Requests.
Ausschließlich Standard-Browser-APIs `chrome.tabs` um die Kontrolle über den New Tab zurückzugewinnen.
Nur Standard-Browser-APIs, `chrome.tabs`, um zurückzubekommen was eigentlich
standardmäßig funktionieren sollte.
**100% lokal. 0% Analytics. Wie im gesamten Hellion NewTab Projekt.**
+56
View File
@@ -32,3 +32,59 @@ chrome.tabs.onActivated.addListener((activeInfo) => {
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);
+347 -21
View File
@@ -3,29 +3,122 @@
Settings Panel, Theme-Modal, Accordion, Toggles
============================================= */
// ---- A11Y: Fokus-Management fuer Modals ----
// Merkt sich das vor dem Oeffnen fokussierte Element, damit wir es beim
// Schliessen restaurieren koennen. Pro offenem Modal eine Closure-Variable.
const _focusReturn = { settings: null, theme: null };
/** Liefert die fokussierbaren Elemente innerhalb eines Containers. */
function _focusable(container) {
return Array.from(container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)).filter(el => el.offsetParent !== null);
}
/** Tab/Shift+Tab im Container einfangen + Escape schliesst. */
function _makeTrap(container, closeFn) {
return function trap(e) {
// Ein offener HellionDialog (z.B. Reset-All-Confirm oder BG-URL-Alert aus
// dem Panel) hat Vorrang: sein eigener keydown-Handler uebernimmt Escape/Tab.
// Sonst schloessen beide Listener gleichzeitig und die Dialog-Fokusfalle wird loechrig.
if (document.querySelector('.dialog-overlay')) return;
if (e.key === 'Escape') { e.preventDefault(); closeFn(); return; }
if (e.key !== 'Tab') return;
const items = _focusable(container);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
};
}
// ---- SETTINGS PANEL ----
// Hinweis: withViewTransition (Phase 4) bleibt fuer das Fade erhalten; das
// Fokus-Management (merken, Falle, Rueckgabe) liegt bewusst ausserhalb des
// Transition-Callbacks. activeElement wird vor der Mutation gelesen.
let _settingsTrap = null;
function openSettings() {
document.getElementById('settingsPanel').classList.add('open');
document.getElementById('settingsOverlay').classList.add('active');
const panel = document.getElementById('settingsPanel');
_focusReturn.settings = document.activeElement;
withViewTransition(() => {
panel.classList.add('open');
document.getElementById('settingsOverlay').classList.add('active');
});
panel.setAttribute('aria-hidden', 'false');
renderTrash();
_settingsTrap = _makeTrap(panel, closeSettings);
document.addEventListener('keydown', _settingsTrap);
const first = _focusable(panel)[0];
if (first) first.focus();
}
function closeSettings() {
document.getElementById('settingsPanel').classList.remove('open');
document.getElementById('settingsOverlay').classList.remove('active');
const panel = document.getElementById('settingsPanel');
withViewTransition(() => {
panel.classList.remove('open');
document.getElementById('settingsOverlay').classList.remove('active');
});
panel.setAttribute('aria-hidden', 'true');
if (_settingsTrap) { document.removeEventListener('keydown', _settingsTrap); _settingsTrap = null; }
if (_focusReturn.settings) { _focusReturn.settings.focus(); _focusReturn.settings = null; }
}
// ---- THEME MODAL ----
let _themeTrap = null;
function openThemeModal() {
const overlay = document.getElementById('themeOverlay');
overlay.classList.add('active');
const modal = document.getElementById('themeModal');
_focusReturn.theme = document.activeElement;
withViewTransition(() => {
overlay.classList.add('active');
});
modal.setAttribute('aria-hidden', 'false');
_themeTrap = _makeTrap(modal, closeThemeModal);
document.addEventListener('keydown', _themeTrap);
const first = _focusable(modal)[0];
if (first) first.focus();
}
function closeThemeModal() {
const overlay = document.getElementById('themeOverlay');
overlay.classList.remove('active');
const modal = document.getElementById('themeModal');
withViewTransition(() => {
overlay.classList.remove('active');
});
modal.setAttribute('aria-hidden', 'true');
if (_themeTrap) { document.removeEventListener('keydown', _themeTrap); _themeTrap = null; }
if (_focusReturn.theme) { _focusReturn.theme.focus(); _focusReturn.theme = null; }
}
/**
* Wechselt das Theme mit nativem Cross-Fade (View Transitions API).
* Wrap sitzt bewusst hier am User-Ausloeser, NICHT in applyTheme(),
* sonst fadet jeder neue Tab beim Initial-Load (settings.js:101).
* Feature-Detection-Fallback: aeltere Browser (z.B. Firefox < 144)
* schalten instant um, ohne Bruch.
* @param {string} name - Theme-Name
*/
function switchTheme(name) {
const swap = () => applyTheme(name, false); // false: Theme-BG anwenden (kein User-bgUrl-Schutz hier noetig, bgUrl wurde geleert)
withViewTransition(swap);
}
/**
* Prueft ob eine Background-URL sicher fuer CSS-Einbettung ist.
* Erlaubt nur blob: und data:image/ Protokolle (aus File Upload).
* @param {string} url
* @returns {boolean}
*/
function isValidBgUrl(url) {
return typeof url === 'string' && url.length > 0 &&
(url.startsWith('blob:') || url.startsWith('data:image/'));
}
// ---- ACCORDION ----
function initAccordion() {
const defaultOpen = new Set(['appearance', 'behavior', 'data', 'help']);
const defaultOpen = new Set(['widgets']);
const sections = document.querySelectorAll('.settings-section[data-section]');
sections.forEach(section => {
@@ -49,6 +142,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 ----
function applySettings() {
const body = document.body;
@@ -71,10 +323,33 @@ function applySettings() {
const showSearchEl = document.getElementById('settingShowSearch');
if (showSearchEl) showSearchEl.checked = settings.showSearch;
// Image-Ref Toggle
if (settings.imageRefEnabled === undefined) settings.imageRefEnabled = false;
const imgRefCheckbox = document.getElementById('settingImageRef');
if (imgRefCheckbox) imgRefCheckbox.checked = settings.imageRefEnabled;
const imgRefBtn = document.querySelector('[data-action="image-ref"]');
if (imgRefBtn) imgRefBtn.classList.toggle('hidden', !settings.imageRefEnabled);
// A11y: aria-checked aller role=switch-Toggles an den realen checked-State angleichen
document.querySelectorAll('.toggle input[role="switch"]').forEach(cb => {
cb.setAttribute('aria-checked', cb.checked ? 'true' : 'false');
});
// Toolbar-Position
document.body.classList.toggle('toolbar-left', settings.toolbarPos === 'left');
const toolbarPosEl = document.getElementById('settingToolbarPos');
if (toolbarPosEl) toolbarPosEl.value = settings.toolbarPos || 'right';
// Sprache (Dropdown-Wert setzen — I18n.init() übernimmt die eigentliche Anwendung)
const langEl = document.getElementById('settingLanguage');
if (langEl) langEl.value = settings.language || 'auto';
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
if (settings.bgUrl) {
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
} else if (settings.bgUrl) {
settings.bgUrl = '';
}
}
@@ -93,15 +368,25 @@ function bindSettingsEvents() {
});
// Theme-Picker (Cards im Theme-Modal)
document.querySelectorAll('.theme-card').forEach(card => {
card.addEventListener('click', async () => {
const name = card.dataset.value;
if (!name || name === settings.theme) return;
settings.theme = name;
settings.bgUrl = '';
document.getElementById('bgUrlInput').value = '';
applyTheme(name, false);
await saveSettings();
const themeCards = document.querySelectorAll('.theme-card');
function selectThemeCard(card) {
const name = card.dataset.value;
if (!name || name === settings.theme) return Promise.resolve();
settings.theme = name;
settings.bgUrl = '';
document.getElementById('bgUrlInput').value = '';
// aria-pressed synchron halten — applyTheme/switchTheme pflegt nur die .active-Klasse, nicht ARIA
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
return saveSettings();
}
themeCards.forEach(card => {
card.addEventListener('click', () => selectThemeCard(card));
card.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectThemeCard(card);
}
});
});
@@ -122,6 +407,11 @@ function bindSettingsEvents() {
settingShowSearch: v => {
settings.showSearch = v;
document.getElementById('searchBarWrapper').classList.toggle('hidden', !v);
},
settingImageRef: v => {
settings.imageRefEnabled = v;
const imgBtn = document.querySelector('[data-action="image-ref"]');
if (imgBtn) imgBtn.classList.toggle('hidden', !v);
}
};
@@ -129,6 +419,7 @@ function bindSettingsEvents() {
const el = document.getElementById(id);
if (el) {
el.addEventListener('change', async e => {
e.target.setAttribute('aria-checked', e.target.checked ? 'true' : 'false');
fn(e.target.checked);
await saveSettings();
});
@@ -147,6 +438,10 @@ function bindSettingsEvents() {
});
document.getElementById('btnApplyBg').addEventListener('click', async () => {
const url = document.getElementById('bgUrlInput').value.trim();
if (url && !isValidBgUrl(url)) {
await HellionDialog.alert(t('settings.bg_invalid_url'), { type: 'danger', title: t('settings.bg_invalid_url.title') });
return;
}
settings.bgUrl = url;
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
await saveSettings();
@@ -162,35 +457,66 @@ function bindSettingsEvents() {
if (!file) return;
const reader = new FileReader();
reader.onload = async ev => {
if (!isValidBgUrl(ev.target.result)) return;
settings.bgUrl = ev.target.result;
document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`;
await saveSettings();
};
reader.onerror = () => {
HellionDialog.alert('Fehler beim Lesen der Datei. Bitte eine andere Datei wählen.', { type: 'danger', title: 'Dateifehler' });
HellionDialog.alert(t('settings.file_read_error'), { type: 'danger', title: t('settings.file_read_error.title') });
};
reader.readAsDataURL(file);
});
// Sprach-Einstellung
const languageEl = document.getElementById('settingLanguage');
if (languageEl) {
languageEl.value = settings.language || 'auto';
languageEl.addEventListener('change', async (e) => {
settings.language = e.target.value;
setLanguage(e.target.value);
await saveSettings();
});
}
// Toolbar-Position Setting
const toolbarPosEl = document.getElementById('settingToolbarPos');
if (toolbarPosEl) {
toolbarPosEl.value = settings.toolbarPos || 'right';
toolbarPosEl.addEventListener('change', async (e) => {
settings.toolbarPos = e.target.value;
document.body.classList.toggle('toolbar-left', e.target.value === 'left');
await saveSettings();
});
}
// Onboarding wiederholen
document.getElementById('btnRestartOnboarding').addEventListener('click', () => {
closeSettings();
Onboarding.start();
});
// Papierkorb leeren
const btnEmptyTrash = document.getElementById('btnEmptyTrash');
if (btnEmptyTrash) btnEmptyTrash.addEventListener('click', emptyTrash);
// Reset All
document.getElementById('btnResetAll').addEventListener('click', async () => {
const ok = await HellionDialog.confirm(
'Wirklich alle Boards und Einstellungen löschen? Das kann nicht rückgängig gemacht werden.',
{ type: 'danger', title: 'Alles zurücksetzen', confirmText: 'Alles löschen' }
t('settings.reset_confirm'),
{ type: 'danger', title: t('settings.reset_confirm.title'), confirmText: t('settings.reset_confirm.button') }
);
if (!ok) return;
boards = [];
trash = [];
settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false,
hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula',
showSearch: true, searchEngine: 'google' };
showSearch: true, searchEngine: 'google', toolbarPos: 'right',
imageRefEnabled: false, language: 'auto' };
await saveBoards();
await saveTrash();
await saveSettings();
setLanguage('auto');
applySettings();
renderBoards();
closeSettings();
+72 -14
View File
@@ -5,6 +5,17 @@
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 = {
compact: false,
shortenTitles: false,
@@ -15,12 +26,14 @@ let settings = {
bgUrl: '',
theme: 'nebula',
showSearch: true,
searchEngine: 'google'
searchEngine: 'google',
toolbarPos: 'right',
imageRefEnabled: false,
language: 'auto'
};
function uid() {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
// uid() lebt jetzt in quicksave-core.js (globalThis.uid), damit Seite und
// Background-Worker dieselbe ID-Erzeugung teilen. Hier bewusst KEINE eigene Deklaration.
function escHtml(str) {
return String(str)
@@ -30,15 +43,6 @@ function escHtml(str) {
.replace(/"/g, '&quot;');
}
function getFaviconUrl(url) {
try {
const u = new URL(url);
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=16`;
} catch {
return '';
}
}
function getDefaultBoards() {
return [
{
@@ -49,7 +53,9 @@ function getDefaultBoards() {
{ id: uid(), title: 'MDN Web Docs', url: 'https://developer.mozilla.org', desc: '' },
{ id: uid(), title: 'Next.js Docs', url: 'https://nextjs.org/docs', desc: '' },
],
blurred: false
blurred: false,
locked: false,
pos: { x: 40, y: 110 }
}
];
}
@@ -58,6 +64,58 @@ async function saveBoards() {
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() {
await Store.set('settings', settings);
}
// ---- VIEW TRANSITIONS ----
// Fuehrt eine synchrone DOM-Mutation mit nativem View-Transition-Fade aus.
// Feature-Detection-Fallback (Firefox < 144): instant. reduced-motion kappt das Fade ueber den ungeschichteten @media-Block.
function withViewTransition(mutate) {
if (document.startViewTransition) {
document.startViewTransition(mutate);
} else {
mutate();
}
}
+2 -2
View File
@@ -23,7 +23,7 @@ const Store = {
chrome.storage.local.set({ [key]: value }, () => {
if (chrome.runtime.lastError) {
console.error('Storage-Fehler:', chrome.runtime.lastError.message);
HellionDialog.alert('Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.', { type: 'danger', title: 'Speicher voll' });
HellionDialog.alert(t('storage.quota_full'), { type: 'danger', title: t('storage.quota_full.title') });
reject(new Error(chrome.runtime.lastError.message));
return;
}
@@ -35,7 +35,7 @@ const Store = {
resolve();
} catch (e) {
console.error('Storage-Fehler:', e.message);
HellionDialog.alert('Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.', { type: 'danger', title: 'Speicher voll' });
HellionDialog.alert(t('storage.quota_full'), { type: 'danger', title: t('storage.quota_full.title') });
reject(e);
}
}
+10 -7
View File
@@ -4,14 +4,17 @@
============================================= */
const THEMES = {
'nebula': { bg: 'assets/themes/bg-nebula.jpg' },
'crescent': { bg: 'assets/themes/bg-crescent.jpg' },
'event-horizon': { bg: 'assets/themes/bg-event-horizon.jpg' },
'nebula': { bg: 'assets/themes/bg-nebula.webp' },
'crescent': { bg: 'assets/themes/bg-crescent.webp' },
'event-horizon': { bg: 'assets/themes/bg-event-horizon.webp' },
'merchantman': { bg: 'assets/themes/bg-merchantman.webp' },
'julia-jin': { bg: 'assets/themes/bg-julia-jin.png' },
'sc-sunset': { bg: 'assets/themes/bg-sc-sunset.jpg' },
'hellion-hud': { bg: 'assets/themes/bg-hellion-hud.png' },
'hellion-energy': { bg: 'assets/themes/bg-hellion-energy.jpg' }
'julia-jin': { bg: 'assets/themes/bg-julia-jin.webp' },
'sc-sunset': { bg: 'assets/themes/bg-sc-sunset.webp' },
'hellion-hud': { bg: 'assets/themes/bg-hellion-hud.webp' },
'hellion-energy': { bg: 'assets/themes/bg-hellion-energy.webp' },
'satisfactory': { bg: 'assets/themes/bg-satisfactory.webp' },
'avorion': { bg: 'assets/themes/bg-avorion.webp' },
'hellion-stealth': { bg: 'assets/themes/bg-scPolaris.webp' }
};
function applyTheme(themeName, skipBgOverride) {
+751
View File
@@ -0,0 +1,751 @@
/* =============================================
HELLION NEWTAB — timer.js
Timer / Countdown Widget: Presets, Alarm,
Tab-Titel-Blink
============================================= */
const Timer = {
WIDGET_ID: 'widget_timer',
STORAGE_KEY: 'widgetStates',
MAX_PRESETS: 5,
/** @type {Array<{name: string, seconds: number}>} */
_presets: [],
_isOpen: false,
_seconds: 0,
_remaining: 0,
_intervalId: null,
_running: false,
_finished: false,
_blinkIntervalId: null,
_originalTitle: '',
_keydownHandler: null,
_muted: false,
// UI-Referenzen
_timeEl: null,
_muteBtn: null,
_inputEl: null,
_inputRow: null,
_btnStart: null,
_btnPause: null,
_btnReset: null,
// ---- STORAGE ----
/**
* Timer-State aus Storage laden
*/
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.timer) {
this._presets = Array.isArray(data.timer.presets) ? data.timer.presets : [];
if (typeof data.timer.muted === 'boolean') this._muted = data.timer.muted;
}
},
/**
* Timer-State in Storage speichern
* Bestehende Notes + Calculator bleiben erhalten
*/
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
if (data.notes === undefined) data.notes = [];
const widgetState = WidgetManager.getState(this.WIDGET_ID);
data.timer = {
x: widgetState ? widgetState.x : 600,
y: widgetState ? widgetState.y : 80,
width: widgetState ? widgetState.width : 260,
height: widgetState ? widgetState.height : 360,
open: this._isOpen,
presets: this._presets.slice(0, this.MAX_PRESETS),
muted: this._muted
};
await Store.set(this.STORAGE_KEY, data);
},
// ---- WIDGET LIFECYCLE ----
/**
* Timer-Widget oeffnen oder in Vordergrund bringen
*/
async open() {
if (this._isOpen) {
WidgetManager.bringToFront(this.WIDGET_ID);
return;
}
const data = await Store.get(this.STORAGE_KEY);
const saved = (data && data.timer) ? data.timer : {};
WidgetManager.create('timer', {
id: this.WIDGET_ID,
title: t('timer.title'),
x: saved.x || 600,
y: saved.y || 80,
width: saved.width || 260,
height: saved.height || 360,
open: true
});
const body = WidgetManager.getBody(this.WIDGET_ID);
if (body) this.renderBody(body);
this._isOpen = true;
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) this._bindKeyboard(entry.el);
await this.save();
},
/**
* Timer toggle: oeffnen oder minimieren
*/
async toggle() {
if (this._isOpen) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry && entry.state.open) {
await WidgetManager.minimize(this.WIDGET_ID);
this._isOpen = false;
await this.save();
} else if (entry) {
await WidgetManager.openWidget(this.WIDGET_ID);
this._isOpen = true;
await this.save();
}
} else {
await this.open();
}
},
/**
* Wird aufgerufen wenn Widget geschlossen wird
*/
async onClose() {
this._isOpen = false;
this._unbindKeyboard();
this._stopCountdown();
this._stopAlarm();
this._timeEl = null;
this._inputEl = null;
this._inputRow = null;
this._btnStart = null;
this._btnPause = null;
this._btnReset = null;
this._muteBtn = null;
await this.save();
},
// ---- UI RENDERING ----
/**
* Timer-Body rendern
* @param {HTMLElement} bodyEl
*/
renderBody(bodyEl) {
bodyEl.textContent = '';
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
// Display
const display = document.createElement('div');
display.className = 'timer-display';
const timeEl = document.createElement('div');
timeEl.className = 'timer-time';
timeEl.textContent = '00:00';
this._timeEl = timeEl;
display.appendChild(timeEl);
// Input
const inputRow = document.createElement('div');
inputRow.className = 'timer-input-row';
this._inputRow = inputRow;
const input = document.createElement('input');
input.className = 'timer-input';
input.type = 'text';
input.placeholder = 'mm:ss';
input.maxLength = 8;
this._inputEl = input;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this._applyInput();
this._start();
}
});
inputRow.appendChild(input);
// Controls
const controls = document.createElement('div');
controls.className = 'timer-controls';
const btnStart = document.createElement('button');
btnStart.className = 'timer-ctrl-btn primary';
btnStart.type = 'button';
btnStart.textContent = t('timer.start');
btnStart.addEventListener('click', () => {
if (!this._running && this._remaining === 0) {
this._applyInput();
}
this._start();
});
this._btnStart = btnStart;
const btnPause = document.createElement('button');
btnPause.className = 'timer-ctrl-btn';
btnPause.type = 'button';
btnPause.textContent = t('timer.pause');
btnPause.disabled = true;
btnPause.addEventListener('click', () => this._pause());
this._btnPause = btnPause;
const btnReset = document.createElement('button');
btnReset.className = 'timer-ctrl-btn danger';
btnReset.type = 'button';
btnReset.textContent = t('timer.reset');
btnReset.addEventListener('click', () => this._reset());
this._btnReset = btnReset;
controls.append(btnStart, btnPause, btnReset);
// Mute Toggle (in Controls-Zeile)
const muteBtn = document.createElement('button');
muteBtn.className = 'timer-mute-btn';
muteBtn.type = 'button';
this._muteBtn = muteBtn;
this._updateMuteBtn();
muteBtn.addEventListener('click', async () => {
this._muted = !this._muted;
this._updateMuteBtn();
await this.save();
});
controls.appendChild(muteBtn);
// Presets
const presetsEl = this._createPresetsPanel();
bodyEl.append(display, inputRow, controls, presetsEl);
// State wiederherstellen
this._updateDisplay();
this._updateControls();
},
/**
* Presets-Panel erstellen
* @returns {HTMLElement}
*/
_createPresetsPanel() {
const container = document.createElement('div');
container.className = 'timer-presets';
container.id = 'timerPresetsPanel';
const header = document.createElement('div');
header.className = 'timer-presets-header';
const title = document.createElement('span');
title.className = 'timer-presets-title';
title.textContent = t('timer.presets');
const addBtn = document.createElement('button');
addBtn.className = 'timer-preset-add';
addBtn.type = 'button';
addBtn.textContent = '+';
addBtn.title = t('timer.save_preset');
addBtn.addEventListener('click', () => this._showAddPreset(container));
header.append(title, addBtn);
container.appendChild(header);
this._renderPresetItems(container);
return container;
},
/**
* Preset-Items rendern
* @param {HTMLElement} container
*/
_renderPresetItems(container) {
// Alte Items entfernen
const oldItems = container.querySelectorAll('.timer-preset-item, .timer-add-row');
oldItems.forEach(item => item.remove());
this._presets.forEach((preset, idx) => {
const item = document.createElement('div');
item.className = 'timer-preset-item';
const name = document.createElement('span');
name.className = 'timer-preset-name';
name.textContent = preset.name;
const time = document.createElement('span');
time.className = 'timer-preset-time';
time.textContent = this._formatTime(preset.seconds);
const del = document.createElement('button');
del.className = 'timer-preset-del';
del.type = 'button';
del.textContent = '\u2715';
del.addEventListener('click', async (e) => {
e.stopPropagation();
await this._deletePreset(idx);
this._renderPresetItems(container);
});
item.append(name, time, del);
// Klick laedt Preset
item.addEventListener('click', () => {
this._loadPreset(preset);
});
container.appendChild(item);
});
},
/**
* Add-Preset UI anzeigen
* @param {HTMLElement} container
*/
_showAddPreset(container) {
// Nur einmal anzeigen
if (container.querySelector('.timer-add-row')) return;
if (this._presets.length >= this.MAX_PRESETS) {
HellionDialog.alert(
t('timer.limit_message', { max: this.MAX_PRESETS }),
{ type: 'warning', title: t('timer.limit_title') }
);
return;
}
// Aktuelle Zeit als Vorlage
const currentSeconds = this._remaining > 0 ? this._seconds : 0;
if (currentSeconds === 0 && this._inputEl) {
const parsed = this._parseTimeInput(this._inputEl.value);
if (parsed === 0) {
HellionDialog.alert(
t('timer.no_time_message'),
{ type: 'info', title: t('timer.no_time_title') }
);
return;
}
}
const row = document.createElement('div');
row.className = 'timer-add-row';
const nameInput = document.createElement('input');
nameInput.className = 'timer-add-input';
nameInput.type = 'text';
nameInput.placeholder = t('timer.preset_name_placeholder');
nameInput.maxLength = 20;
const confirmBtn = document.createElement('button');
confirmBtn.className = 'timer-add-confirm';
confirmBtn.type = 'button';
confirmBtn.textContent = t('timer.ok');
const doAdd = async () => {
const name = nameInput.value.trim();
if (!name) return;
let secs = this._seconds;
if (secs === 0 && this._inputEl) {
secs = this._parseTimeInput(this._inputEl.value);
}
if (secs === 0) return;
await this._addPreset(name, secs);
this._renderPresetItems(container);
};
confirmBtn.addEventListener('click', doAdd);
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doAdd();
if (e.key === 'Escape') row.remove();
});
row.append(nameInput, confirmBtn);
container.appendChild(row);
nameInput.focus();
},
// ---- TIMER LOGIC ----
/**
* Input-Feld auslesen und als Sekunden setzen
*/
_applyInput() {
if (!this._inputEl) return;
const secs = this._parseTimeInput(this._inputEl.value);
if (secs > 0) {
this._seconds = secs;
this._remaining = secs;
}
},
/**
* Timer starten
*/
_start() {
if (this._running) return;
if (this._remaining <= 0) return;
// Falls gerade Alarm laeuft, stoppen
if (this._finished) {
this._stopAlarm();
this._finished = false;
}
this._running = true;
this._updateControls();
// Input verstecken
if (this._inputRow) this._inputRow.style.display = 'none';
this._intervalId = setInterval(() => this._tick(), 1000);
},
/**
* Timer pausieren
*/
_pause() {
if (!this._running) return;
this._running = false;
this._stopCountdown();
this._updateControls();
},
/**
* Timer zuruecksetzen
*/
_reset() {
this._stopCountdown();
this._stopAlarm();
this._running = false;
this._finished = false;
this._remaining = 0;
this._seconds = 0;
// Input wieder anzeigen
if (this._inputRow) this._inputRow.style.display = 'flex';
if (this._inputEl) this._inputEl.value = '';
this._updateDisplay();
this._updateControls();
},
/**
* Jede Sekunde: remaining verringern, Display aktualisieren
*/
_tick() {
this._remaining--;
if (this._remaining <= 0) {
this._remaining = 0;
this._stopCountdown();
this._running = false;
this._finished = true;
this._onFinish();
}
this._updateDisplay();
this._updateControls();
},
/**
* Interval stoppen
*/
_stopCountdown() {
if (this._intervalId) {
clearInterval(this._intervalId);
this._intervalId = null;
}
},
/**
* Timer abgelaufen — Alarm + Tab-Blink
*/
_onFinish() {
if (!this._muted) this._playAlarm();
this._startTitleBlink();
},
/**
* Akustisches Signal (Browser Audio API, kein externer Request)
*/
_playAlarm() {
try {
const ctx = new AudioContext();
[0, 0.3, 0.6].forEach(delay => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 800;
gain.gain.value = 0.07;
osc.start(ctx.currentTime + delay);
osc.stop(ctx.currentTime + delay + 0.2);
});
} catch (e) {
console.warn('Timer: Audio nicht verfuegbar', e);
}
},
/**
* Tab-Titel blinken lassen
*/
_startTitleBlink() {
this._originalTitle = document.title;
this._blinkIntervalId = setInterval(() => {
document.title = document.title === t('timer.finished_title')
? this._originalTitle
: t('timer.finished_title');
}, 1000);
},
/**
* Tab-Titel Blink und Alarm stoppen
*/
_stopAlarm() {
if (this._blinkIntervalId) {
clearInterval(this._blinkIntervalId);
this._blinkIntervalId = null;
document.title = this._originalTitle || t('timer.default_page_title');
}
this._finished = false;
this._updateDisplay();
this._updateControls();
},
/**
* Mute-Button Text/Titel aktualisieren
*/
_updateMuteBtn() {
if (!this._muteBtn) return;
this._muteBtn.textContent = this._muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
this._muteBtn.title = this._muted ? t('timer.unmute') : t('timer.mute');
this._muteBtn.classList.toggle('muted', this._muted);
},
// ---- DISPLAY ----
/**
* Zeitanzeige aktualisieren
*/
_updateDisplay() {
if (!this._timeEl) return;
this._timeEl.textContent = this._formatTime(this._remaining);
this._timeEl.classList.toggle('finished', this._finished);
},
/**
* Button-States aktualisieren
*/
_updateControls() {
if (this._btnStart) {
this._btnStart.disabled = this._running;
this._btnStart.textContent = this._finished ? t('timer.restart') : t('timer.start');
}
if (this._btnPause) {
this._btnPause.disabled = !this._running;
}
},
// ---- PRESETS ----
/**
* Preset hinzufuegen
* @param {string} name
* @param {number} seconds
*/
async _addPreset(name, seconds) {
if (this._presets.length >= this.MAX_PRESETS) return;
this._presets.push({ name, seconds });
await this.save();
},
/**
* Preset loeschen
* @param {number} index
*/
async _deletePreset(index) {
this._presets.splice(index, 1);
await this.save();
},
/**
* Preset laden (Zeit setzen)
* @param {Object} preset - { name, seconds }
*/
_loadPreset(preset) {
// Falls laufend, erst stoppen
this._stopCountdown();
this._stopAlarm();
this._running = false;
this._finished = false;
this._seconds = preset.seconds;
this._remaining = preset.seconds;
if (this._inputRow) this._inputRow.style.display = 'none';
this._updateDisplay();
this._updateControls();
},
// ---- FORMATTING ----
/**
* Sekunden in Zeitformat umwandeln
* @param {number} totalSeconds
* @returns {string} "05:30" oder "1:05:30"
*/
_formatTime(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
const mm = String(m).padStart(2, '0');
const ss = String(s).padStart(2, '0');
if (h > 0) {
return h + ':' + mm + ':' + ss;
}
return mm + ':' + ss;
},
/**
* Zeit-String in Sekunden parsen
* Akzeptiert: "5:30", "05:30", "1:05:30", "90" (Sekunden)
* @param {string} str
* @returns {number}
*/
_parseTimeInput(str) {
const trimmed = (str || '').trim();
if (!trimmed) return 0;
const parts = trimmed.split(':');
if (parts.length === 1) {
// Nur Zahl = Sekunden
const secs = parseInt(parts[0], 10);
return isNaN(secs) ? 0 : Math.max(0, secs);
}
if (parts.length === 2) {
// mm:ss
const m = parseInt(parts[0], 10);
const s = parseInt(parts[1], 10);
if (isNaN(m) || isNaN(s)) return 0;
return Math.max(0, m * 60 + s);
}
if (parts.length === 3) {
// hh:mm:ss
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
const s = parseInt(parts[2], 10);
if (isNaN(h) || isNaN(m) || isNaN(s)) return 0;
return Math.max(0, h * 3600 + m * 60 + s);
}
return 0;
},
// ---- KEYBOARD ----
/**
* Tastatur-Events binden
* @param {HTMLElement} widgetEl
*/
_bindKeyboard(widgetEl) {
this._unbindKeyboard();
this._keydownHandler = (e) => {
// Nicht reagieren wenn User in Input tippt
if (e.target.tagName === 'INPUT') return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (this._running) {
this._pause();
} else if (this._remaining > 0) {
this._start();
}
} else if (e.key === 'Escape' || e.key === 'r' || e.key === 'R') {
e.preventDefault();
this._reset();
}
};
widgetEl.addEventListener('keydown', this._keydownHandler);
widgetEl.tabIndex = 0;
},
/**
* Keyboard-Events entfernen
*/
_unbindKeyboard() {
if (this._keydownHandler) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) {
entry.el.removeEventListener('keydown', this._keydownHandler);
}
this._keydownHandler = null;
}
},
// ---- INIT ----
/**
* Timer initialisieren (aus app.js aufgerufen)
*/
async init() {
await this.load();
// Wenn Timer beim letzten Mal offen war, wiederherstellen
const data = await Store.get(this.STORAGE_KEY);
if (data && data.timer && data.timer.open) {
await this.open();
}
// Widget-Lifecycle-Events
const self = this;
WidgetManager.on('widget:close', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self.onClose();
}
});
WidgetManager.on('widget:minimize', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self._isOpen = false;
self.save();
}
});
WidgetManager.on('widget:open', (e) => {
if (e.detail.id === self.WIDGET_ID) {
self._isOpen = true;
const body = WidgetManager.getBody(self.WIDGET_ID);
if (body && body.children.length === 0) {
self.renderBody(body);
}
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
if (entry) self._bindKeyboard(entry.el);
self.save();
}
});
}
};
+412
View File
@@ -0,0 +1,412 @@
/* =============================================
HELLION NEWTAB — widgets.js
Widget-Manager: Registry, Drag, Resize, Z-Index, Persistierung
============================================= */
const WidgetManager = {
/** @type {Map<string, {el: HTMLElement, type: string, state: Object}>} */
_widgets: new Map(),
_topZ: 100,
STORAGE_KEY: 'widgetStates',
/** @type {EventTarget} Internes Event-System fuer Widget-Lifecycle */
_emitter: new EventTarget(),
/**
* Event-Listener registrieren
* @param {string} event - z.B. 'widget:close', 'widget:minimize', 'widget:open'
* @param {Function} handler
*/
on(event, handler) {
this._emitter.addEventListener(event, handler);
},
/**
* Event-Listener entfernen
* @param {string} event
* @param {Function} handler
*/
off(event, handler) {
this._emitter.removeEventListener(event, handler);
},
/**
* Widget erstellen und in DOM einfuegen
* @param {string} type - 'note'
* @param {Object} config - { id, title, x, y, width, height, open }
* @returns {string} widget-id
*/
create(type, config) {
const id = config.id || ('widget_' + uid());
const state = {
id,
type,
title: config.title || t('notes.default_title'),
x: config.x || 120,
y: config.y || 80,
width: config.width || 280,
height: config.height || 220,
open: config.open !== false
};
const el = this._buildDOM(state);
document.body.appendChild(el);
this._widgets.set(id, { el, type, state, _minimizing: false });
this._initDrag(el);
this._initResize(el);
this.bringToFront(id);
return id;
},
/**
* Widget-DOM erzeugen (createElement, kein innerHTML)
* @param {Object} state
* @returns {HTMLElement}
*/
_buildDOM(state) {
const widget = document.createElement('div');
widget.className = 'widget';
widget.dataset.widgetId = state.id;
widget.style.left = state.x + 'px';
widget.style.top = state.y + 'px';
widget.style.width = state.width + 'px';
widget.style.height = state.height + 'px';
// Header
const header = document.createElement('div');
header.className = 'widget-header';
const title = document.createElement('span');
title.className = 'widget-title';
title.textContent = state.title;
// Doppelklick auf Titel zum Editieren
title.addEventListener('dblclick', () => {
title.contentEditable = 'true';
title.focus();
// Text selektieren
const range = document.createRange();
range.selectNodeContents(title);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
});
title.addEventListener('blur', async () => {
title.contentEditable = 'false';
const newTitle = title.textContent.trim().slice(0, 20);
title.textContent = newTitle || t('notes.default_title');
const entry = this._widgets.get(state.id);
if (entry) {
entry.state.title = title.textContent;
await this.save();
}
});
title.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
title.blur();
}
});
const actions = document.createElement('div');
actions.className = 'widget-actions';
const btnMin = document.createElement('button');
btnMin.className = 'widget-btn widget-minimize';
btnMin.title = t('widget.minimize');
btnMin.textContent = '\u2500';
btnMin.addEventListener('click', () => this.minimize(state.id));
const btnClose = document.createElement('button');
btnClose.className = 'widget-btn widget-close';
btnClose.title = t('widget.close');
btnClose.textContent = '\u2715';
btnClose.addEventListener('click', () => this.close(state.id));
actions.append(btnMin, btnClose);
header.append(title, actions);
// Body
const body = document.createElement('div');
body.className = 'widget-body';
// Resize Handle
const resizeHandle = document.createElement('div');
resizeHandle.className = 'widget-resize-handle';
widget.append(header, body, resizeHandle);
// Klick auf Widget bringt es nach vorne
widget.addEventListener('pointerdown', () => {
this.bringToFront(state.id);
});
return widget;
},
/**
* Widget-Body-Element holen
* @param {string} id
* @returns {HTMLElement|null}
*/
getBody(id) {
const entry = this._widgets.get(id);
if (!entry) return null;
return entry.el.querySelector('.widget-body');
},
/**
* Widget entfernen (endgueltig loeschen)
* @param {string} id
*/
close(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry.el.remove();
this._emitter.dispatchEvent(new CustomEvent('widget:close', { detail: { id } }));
this._widgets.delete(id);
},
/**
* Widget minimieren (aus DOM verstecken, bleibt im Notebook).
* Nutzt transitionend statt setTimeout — _minimizing Flag verhindert Race Condition
* mit openWidget(). Fallback-Timer fuer prefers-reduced-motion / fehlende Transition.
* @param {string} id
*/
async minimize(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry.state.open = false;
entry._minimizing = true;
entry.el.classList.add('widget-minimized');
const MINIMIZE_FALLBACK_MS = 350;
function onEnd(e) {
if (e.target !== entry.el || e.propertyName !== 'opacity') return;
clearTimeout(fallbackTimer);
entry.el.removeEventListener('transitionend', onEnd);
if (entry._minimizing) {
entry.el.style.display = 'none';
}
entry._minimizing = false;
}
entry.el.addEventListener('transitionend', onEnd);
const fallbackTimer = setTimeout(() => {
entry.el.removeEventListener('transitionend', onEnd);
if (entry._minimizing) {
entry.el.style.display = 'none';
entry._minimizing = false;
}
}, MINIMIZE_FALLBACK_MS);
await this.save();
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
},
/**
* Widget oeffnen (aus minimiertem Zustand wiederherstellen)
* @param {string} id
*/
async openWidget(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry._minimizing = false;
entry.state.open = true;
entry.el.style.display = 'flex';
requestAnimationFrame(() => {
entry.el.classList.remove('widget-minimized');
});
this.bringToFront(id);
await this.save();
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
},
/**
* Widget in den Vordergrund bringen
* @param {string} id
*/
bringToFront(id) {
const entry = this._widgets.get(id);
if (!entry) return;
this._topZ++;
entry.el.style.zIndex = this._topZ;
},
/**
* Drag initialisieren (Pointer Events auf Header)
* @param {HTMLElement} widgetEl
*/
_initDrag(widgetEl) {
const header = widgetEl.querySelector('.widget-header');
const self = this;
header.addEventListener('pointerdown', function onDown(e) {
if (e.target.closest('.widget-btn') || e.target.closest('.widget-title[contenteditable="true"]')) return;
e.preventDefault();
header.setPointerCapture(e.pointerId);
const rect = widgetEl.getBoundingClientRect();
const offX = e.clientX - rect.left;
const offY = e.clientY - rect.top;
function onMove(ev) {
const maxX = window.innerWidth - widgetEl.offsetWidth;
const maxY = window.innerHeight - widgetEl.offsetHeight;
widgetEl.style.left = Math.max(0, Math.min(maxX, ev.clientX - offX)) + 'px';
widgetEl.style.top = Math.max(48, Math.min(maxY, ev.clientY - offY)) + 'px';
}
async function onUp() {
header.releasePointerCapture(e.pointerId);
header.removeEventListener('pointermove', onMove);
header.removeEventListener('pointerup', onUp);
// State aktualisieren
const id = widgetEl.dataset.widgetId;
const entry = self._widgets.get(id);
if (entry) {
entry.state.x = parseFloat(widgetEl.style.left);
entry.state.y = parseFloat(widgetEl.style.top);
await self.save();
}
}
header.addEventListener('pointermove', onMove);
header.addEventListener('pointerup', onUp);
});
},
/**
* Resize initialisieren (Pointer Events auf Handle)
* @param {HTMLElement} widgetEl
*/
_initResize(widgetEl) {
const handle = widgetEl.querySelector('.widget-resize-handle');
const self = this;
handle.addEventListener('pointerdown', function onDown(e) {
e.preventDefault();
e.stopPropagation();
handle.setPointerCapture(e.pointerId);
const startW = widgetEl.offsetWidth;
const startH = widgetEl.offsetHeight;
const startX = e.clientX;
const startY = e.clientY;
function onMove(ev) {
widgetEl.style.width = Math.max(200, startW + (ev.clientX - startX)) + 'px';
widgetEl.style.height = Math.max(150, startH + (ev.clientY - startY)) + 'px';
}
async function onUp() {
handle.releasePointerCapture(e.pointerId);
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
const id = widgetEl.dataset.widgetId;
const entry = self._widgets.get(id);
if (entry) {
entry.state.width = widgetEl.offsetWidth;
entry.state.height = widgetEl.offsetHeight;
await self.save();
}
}
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
});
},
/**
* Alle Widget-States aus Storage laden und wiederherstellen
* @param {Function} renderCallback - Funktion die den Body rendert (noteData, bodyEl)
*/
async restore(renderCallback) {
const data = await Store.get(this.STORAGE_KEY);
if (!data || !Array.isArray(data.notes)) return;
for (const noteData of data.notes) {
const id = this.create('note', {
id: noteData.id,
title: noteData.title,
x: noteData.x,
y: noteData.y,
width: noteData.width,
height: noteData.height,
open: noteData.open
});
// Body rendern lassen (von Notes-Modul)
if (renderCallback) {
const body = this.getBody(id);
if (body) renderCallback(noteData, body);
}
// Falls minimiert, sofort verstecken
if (!noteData.open) {
const entry = this._widgets.get(id);
if (entry) {
entry.el.classList.add('widget-minimized');
entry.el.style.display = 'none';
}
}
}
},
/**
* Alle Widget-States speichern
*/
async save() {
const notes = [];
for (const [id, entry] of this._widgets) {
if (entry.type === 'note') {
notes.push({
...entry.state,
// Zusaetzliche Note-Daten werden von Notes.save() ergaenzt
});
}
}
// Nicht direkt speichern — Notes-Modul merged die Daten
return notes;
},
/**
* Widget-State fuer eine bestimmte ID holen
* @param {string} id
* @returns {Object|null}
*/
getState(id) {
const entry = this._widgets.get(id);
return entry ? entry.state : null;
},
/**
* Pruefen ob Widget offen ist
* @param {string} id
* @returns {boolean}
*/
isOpen(id) {
const entry = this._widgets.get(id);
return entry ? entry.state.open : false;
},
/**
* Anzahl aller Widgets
* @returns {number}
*/
count() {
return this._widgets.size;
},
/**
* Alle Widget-IDs
* @returns {string[]}
*/
getAllIds() {
return Array.from(this._widgets.keys());
}
};