12 Commits

Author SHA1 Message Date
JonKazama-Hellion 8dc7a73811 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 08:54:57 +01:00
JonKazama-Hellion f08d5d7563 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 00:47:51 +01:00
JonKazama-Hellion b55bb7ac34 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 00:32:41 +01:00
JonKazama-Hellion 37e45a2041 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 00:13:40 +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
JonKazama-Hellion 6172332be7 docs(release): CHANGELOG, README und GitHub Actions aktualisieren
- CHANGELOG.md mit v1.5.2 Eintrag erstellt
- README.md Architektur-Baum aktualisiert (dialog.js, onboarding.js, opera/)
- release.yml erstellt 3 ZIP-Pakete (Chrome, Firefox, Opera)
- quality.yml prueft alle 3 Manifests auf V3 und Versions-Konsistenz
2026-03-21 19:08:45 +01:00
JonKazama-Hellion 74e3eaefcf refactor(settings): Theme-Modal auslagern, Accordion-Sektionen
- Theme-Picker als eigenes Modal mit Header-Button aus Settings entfernt
- Background-Optionen (URL + Upload) ins Theme-Modal verschoben
- Alle Settings-Sektionen einklappbar mit Chevron-Animation
- About und Danger Zone standardmaessig eingeklappt
- Suchleisten-Toggle von DATA nach BEHAVIOR verschoben
- Quick Save Placeholder-Element entfernt
- STYLE_GUIDE.md fuer Theme-System hinzugefuegt
2026-03-21 19:08:31 +01:00
JonKazama-Hellion 00baa0231b feat(ui): Custom Dialog-System, Onboarding und Backup-Reminder
- HellionDialog.alert/confirm ersetzt alle nativen confirm() und alert() Aufrufe
- 6-stufiger Onboarding-Flow beim ersten Start (Boards, Themes, Features, Backup)
- Backup-Reminder erinnert alle 7 Tage an JSON-Export
- innerHTML komplett durch createElement/createElementNS ersetzt (XSS-Schutz)
- Drag & Drop Inline-Styles durch CSS-Klassen ersetzt
2026-03-21 19:08:17 +01:00
JonKazama-Hellion 36bf38a92c feat(compat): Opera GX Workaround, Firefox MV3, lokale Fonts und Icon-Update
- manifest.opera.json mit Service Worker und Content Script fuer Opera GX Speed Dial
- src/js/opera/background.js und redirect.js fuer Tab-Uebernahme
- manifest.firefox.json auf Manifest V3 migriert mit Gecko-ID
- Fonts von Google Fonts API auf lokale WOFF2-Dateien umgestellt (DSGVO)
- Extension-Icons aktualisiert
- SECURITY.md und DISCLAIMER.md ueberarbeitet
2026-03-21 19:08:03 +01:00
JonKazama-Hellion b430b67df9 Manifestfile füe Mozilla Firefox endlich hinbekommen, das der Bot Check dort klappt 2026-03-21 01:01:22 +01:00
41 changed files with 6549 additions and 638 deletions
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
ko_fi: hellionmedia
+31 -12
View File
@@ -18,43 +18,56 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: HTML-Validierung (newtab.html existiert) - name: Projektstruktur prüfen
run: | run: |
echo "Prüfe Projektstruktur..." echo "Prüfe Projektstruktur..."
test -f manifest.json || (echo "FEHLER: manifest.json fehlt!" && exit 1) test -f manifest.json || (echo "FEHLER: manifest.json fehlt!" && exit 1)
test -f manifest.firefox.json || (echo "FEHLER: manifest.firefox.json fehlt!" && exit 1) test -f manifest.firefox.json || (echo "FEHLER: manifest.firefox.json fehlt!" && exit 1)
test -f manifest.opera.json || (echo "FEHLER: manifest.opera.json fehlt!" && exit 1)
test -f newtab.html || (echo "FEHLER: newtab.html fehlt!" && exit 1) test -f newtab.html || (echo "FEHLER: newtab.html fehlt!" && exit 1)
test -d src/js || (echo "FEHLER: src/js/ fehlt!" && exit 1) test -d src/js || (echo "FEHLER: src/js/ fehlt!" && exit 1)
test -d src/js/opera || (echo "FEHLER: src/js/opera/ fehlt!" && exit 1)
test -d src/css || (echo "FEHLER: src/css/ fehlt!" && exit 1) test -d src/css || (echo "FEHLER: src/css/ fehlt!" && exit 1)
test -d assets/icons || (echo "FEHLER: assets/icons/ fehlt!" && exit 1) test -d assets/icons || (echo "FEHLER: assets/icons/ fehlt!" && exit 1)
test -d assets/themes || (echo "FEHLER: assets/themes/ fehlt!" && exit 1) test -d assets/themes || (echo "FEHLER: assets/themes/ fehlt!" && exit 1)
test -d assets/fonts || (echo "FEHLER: assets/fonts/ fehlt!" && exit 1)
echo "Projektstruktur OK" echo "Projektstruktur OK"
- name: Manifest-Validierung - name: Manifest-Validierung (alle 3)
run: | run: |
echo "Prüfe manifest.json..." echo "Prüfe Manifests..."
python3 -c " python3 -c "
import json, sys import json, sys
with open('manifest.json') as f: with open('manifest.json') as f:
m = json.load(f) m = json.load(f)
assert m.get('manifest_version') == 3, 'Manifest V3 erwartet' assert m.get('manifest_version') == 3, 'Chrome: Manifest V3 erwartet'
assert m.get('name'), 'Name fehlt' assert m.get('name'), 'Chrome: Name fehlt'
assert m.get('version'), 'Version fehlt' assert m.get('version'), 'Chrome: Version fehlt'
assert 'storage' in m.get('permissions', []), 'Storage Permission fehlt' assert 'storage' in m.get('permissions', []), 'Chrome: Storage Permission fehlt'
print('manifest.json (V3) OK — Version:', m['version']) print('manifest.json (V3) OK — Version:', m['version'])
with open('manifest.firefox.json') as f: with open('manifest.firefox.json') as f:
mf = json.load(f) mf = json.load(f)
assert mf.get('manifest_version') == 2, 'Firefox Manifest V2 erwartet' assert mf.get('manifest_version') == 3, 'Firefox: Manifest V3 erwartet'
assert mf['version'] == m['version'], 'Versionen stimmen nicht überein!' assert mf['version'] == m['version'], 'Firefox: Version stimmt nicht mit Chrome überein!'
print('manifest.firefox.json (V2) OK — Version:', mf['version']) assert 'browser_specific_settings' in mf, 'Firefox: browser_specific_settings fehlt'
print('manifest.firefox.json (V3) OK — Version:', mf['version'])
with open('manifest.opera.json') as f:
mo = json.load(f)
assert mo.get('manifest_version') == 3, 'Opera: Manifest V3 erwartet'
assert mo['version'] == m['version'], 'Opera: Version stimmt nicht mit Chrome überein!'
assert 'tabs' in mo.get('permissions', []), 'Opera: Tabs Permission fehlt'
assert 'background' in mo, 'Opera: Background Service Worker fehlt'
print('manifest.opera.json (V3) OK — Version:', mo['version'])
" "
- name: JavaScript Syntax-Check - name: JavaScript Syntax-Check
run: | run: |
echo "Prüfe JavaScript-Syntax..." echo "Prüfe JavaScript-Syntax..."
ERRORS=0 ERRORS=0
for f in src/js/*.js; do for f in src/js/*.js src/js/opera/*.js; do
if ! node --check "$f" 2>&1; then if ! node --check "$f" 2>&1; then
echo "SYNTAX-FEHLER in $f" echo "SYNTAX-FEHLER in $f"
ERRORS=$((ERRORS + 1)) ERRORS=$((ERRORS + 1))
@@ -77,12 +90,18 @@ jobs:
run: | run: |
MANIFEST_VER=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])") MANIFEST_VER=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
FIREFOX_VER=$(python3 -c "import json; print(json.load(open('manifest.firefox.json'))['version'])") FIREFOX_VER=$(python3 -c "import json; print(json.load(open('manifest.firefox.json'))['version'])")
OPERA_VER=$(python3 -c "import json; print(json.load(open('manifest.opera.json'))['version'])")
HTML_VER=$(grep -oP 'Version \K[0-9]+\.[0-9]+\.[0-9]+' newtab.html || echo 'NICHT GEFUNDEN') HTML_VER=$(grep -oP 'Version \K[0-9]+\.[0-9]+\.[0-9]+' newtab.html || echo 'NICHT GEFUNDEN')
echo "manifest.json: $MANIFEST_VER" echo "manifest.json: $MANIFEST_VER"
echo "manifest.firefox.json: $FIREFOX_VER" echo "manifest.firefox.json: $FIREFOX_VER"
echo "manifest.opera.json: $OPERA_VER"
echo "newtab.html: $HTML_VER" echo "newtab.html: $HTML_VER"
if [ "$MANIFEST_VER" != "$FIREFOX_VER" ]; then if [ "$MANIFEST_VER" != "$FIREFOX_VER" ]; then
echo "FEHLER: Versionen in Manifests stimmen nicht überein!" echo "FEHLER: Chrome/Firefox Versionen stimmen nicht überein!"
exit 1
fi
if [ "$MANIFEST_VER" != "$OPERA_VER" ]; then
echo "FEHLER: Chrome/Opera Versionen stimmen nicht überein!"
exit 1 exit 1
fi fi
if [ "$MANIFEST_VER" != "$HTML_VER" ]; then if [ "$MANIFEST_VER" != "$HTML_VER" ]; then
+19 -10
View File
@@ -1,4 +1,4 @@
# Release — erstellt ZIP-Pakete für Chrome und Firefox bei neuem Tag # Release — erstellt ZIP-Pakete für Chrome, Firefox und Opera bei neuem Tag
name: Release name: Release
on: on:
@@ -25,18 +25,25 @@ jobs:
run: | run: |
mkdir -p dist mkdir -p dist
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \ zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \
manifest.json newtab.html src/ assets/ \ manifest.json newtab.html src/js/*.js src/css/ assets/ \
-x "*.git*" "dist/*" ".github/*" -x "*.git*" "dist/*" ".github/*" "src/js/opera/*"
- name: Firefox ZIP erstellen (Manifest V2) - name: Firefox ZIP erstellen (Manifest V3)
run: | run: |
# manifest.firefox.json wird zu manifest.json für Firefox
cp manifest.json manifest.chrome-backup.json cp manifest.json manifest.chrome-backup.json
cp manifest.firefox.json manifest.json cp manifest.firefox.json manifest.json
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \ zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \
manifest.json newtab.html src/ assets/ \ manifest.json newtab.html src/js/*.js src/css/ assets/ \
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.firefox.json" -x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*"
# Wiederherstellen 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 mv manifest.chrome-backup.json manifest.json
- name: SHA256 Checksummen erstellen - name: SHA256 Checksummen erstellen
@@ -53,8 +60,9 @@ jobs:
## Hellion NewTab ${{ steps.version.outputs.tag }} ## Hellion NewTab ${{ steps.version.outputs.tag }}
### Installation ### Installation
- **Chrome / Edge / Brave / Opera / Vivaldi:** `hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip` herunterladen und entpacken - **Chrome / Edge / Brave / Vivaldi:** `hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip`
- **Firefox:** `hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip` herunterladen und entpacken - **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. Siehe [README](README.md) für die vollständige Installationsanleitung.
@@ -63,5 +71,6 @@ jobs:
files: | files: |
dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip 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 }}-firefox.zip
dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip
dist/checksums-sha256.txt dist/checksums-sha256.txt
generate_release_notes: true generate_release_notes: true
+7 -1
View File
@@ -2,7 +2,7 @@
.DS_Store .DS_Store
Thumbs.db Thumbs.db
desktop.ini desktop.ini
CLAUDE.md
# Editor # Editor
.vscode/ .vscode/
.idea/ .idea/
@@ -14,6 +14,12 @@ dist/
*.zip *.zip
*.tar.gz *.tar.gz
node_modules/ 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) # Persönliche Backup-Dateien (nicht ins Repo)
favorites_*.html favorites_*.html
+92
View File
@@ -0,0 +1,92 @@
# ⬡ Hellion Dashboard — Changelog
Alle relevanten Änderungen pro Version. Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
---
### v1.5.2 — 21.03.2026
#### Neue 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)
#### Verbesserungen
- 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
#### 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`
#### Firefox
- `manifest.firefox.json` auf Manifest V3 migriert
- `browser_specific_settings` mit Gecko-ID und `data_collection_permissions`
#### Build & CI
- GitHub Actions: Release erstellt jetzt 3 ZIP-Pakete (Chrome, Firefox, Opera)
- Quality-Check prüft alle 3 Manifests und Opera-Ordner
---
### 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
---
### 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
---
### 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
---
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
+1 -1
View File
@@ -44,4 +44,4 @@ Der Entwickler behält sich das Recht vor, diese Extension jederzeit zu ändern,
--- ---
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion **Hellion NewTab** — [Hellion Online Media - Florian Wathing](https://hellion-media.de) — JonKazama-Hellion
+88 -100
View File
@@ -1,10 +1,11 @@
# ⬡ Hellion NewTab v1.2.0 # ⬡ Hellion Dashboard v1.9.0
![Version](https://img.shields.io/badge/Version-1.2.0-blue) ![Version](https://img.shields.io/badge/Version-1.9.0-blue)
![JavaScript](https://img.shields.io/badge/JavaScript-Vanilla%20ES2020-F7DF1E?logo=javascript&logoColor=black) ![JavaScript](https://img.shields.io/badge/JavaScript-Vanilla%20ES2020-F7DF1E?logo=javascript&logoColor=black)
![Manifest](https://img.shields.io/badge/Manifest-V3%20%7C%20V2-green) ![Manifest](https://img.shields.io/badge/Manifest-V3-green)
![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-orange) ![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%20Lokal-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.** **Kein Account. Kein Abo. Keine Cloud. Alle Daten bleiben 100% lokal.**
@@ -12,7 +13,7 @@ Ein persönlicher Bookmark-Dashboard als Browser-Extension.
Boards, Drag & Drop, 8 Themes, Suchleiste, Sticky Notes — alles im Browser, alles offline. Boards, Drag & Drop, 8 Themes, Suchleiste, Sticky Notes — alles im Browser, alles offline.
Keine externe Datenübertragung, keine Tracker, keine Analytics, keine Werbung. Keine externe Datenübertragung, keine Tracker, keine Analytics, keine Werbung.
Entwickelt von **[Hellion Online Media](https://hellion-media.de)** — JonKazama-Hellion. Entwickelt von **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion.
--- ---
@@ -54,20 +55,20 @@ Was angezeigt wird, ist was gespeichert ist. Keine Magie.
### 8 Themes ### 8 Themes
| Theme | Akzent | Stil | | Theme | Akzent | Stil |
| --- | --- | --- | |---|---|---|
| Nebula | Magenta | Cosmic Nebula | | Nebula | `#b359ff` Magenta | Cosmic Nebula |
| Crescent | Gold | Minimalist Night | | Crescent | `#d4bd8a` Gold | Minimalist Night |
| Event Horizon | Orange | Deep Space | | Event Horizon | `#9d5cff` Purple | Deep Space |
| Merchantman | Teal | Industrial Sci-Fi | | Merchantman | `#2eb8b8` Emerald | Industrial Sci-Fi |
| Julia & Jin | Blau | FFXIV Night | | Julia & Jin | `#7db3ff` Aetherial Blue | FFXIV Night |
| SC Sunset | Amber | Planet-Side | | SC Sunset | `#ff8c3d` Amber | Planet-Side |
| Hellion HUD | Grün | Circuit Board | | Hellion HUD | `#32ff6a` Neon Green | Circuit Board |
| Hellion Energy | Matrix-Grün | Tactical | | Hellion Energy | `#1eff8e` Acid Green | Tactical |
### Bild-Credits ### Bild-Credits
| Theme | Quelle | Lizenz | | Theme | Quelle | Lizenz |
| --- | --- | --- | |---|---|---|
| Nebula | [Temel / mrwashingt0n](https://pixabay.com/de/users/mrwashingt0n-15745216/) auf Pixabay | Pixabay License (frei) | | 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) | | 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 | | Event Horizon | Eigenes Werk — Stillframe von [hellion-initiative.online](https://hellion-initiative.online) | Hellion Online Media |
@@ -77,55 +78,83 @@ Was angezeigt wird, ist was gespeichert ist. Keine Magie.
| Hellion HUD | Eigenes Werk — AI-generiert und nachbearbeitet für hellion-media.de | 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 | | Hellion Energy | Eigenes Werk — AI-generiert für hellion-media.de | Hellion Online Media |
### Settings ### Onboarding & Dialoge
- 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)
### Settings (Accordion)
- Einklappbare Sektionen mit Chevron — About/Danger Zone standardmäßig geschlossen
- Compact Mode — reduziert Abstände für mehr Bookmarks - Compact Mode — reduziert Abstände für mehr Bookmarks
- Shorten Titles — kürzt lange Titel auf eine Zeile - Shorten Titles — kürzt lange Titel auf eine Zeile
- Open in New Tab — Bookmarks in neuem Tab öffnen - Open in New Tab — Bookmarks in neuem Tab öffnen
- Show Descriptions — Beschreibungen unter Bookmarks anzeigen - Show Descriptions — Beschreibungen unter Bookmarks anzeigen
- Hide Extra Bookmarks — Boards ab 5/10/20 Bookmarks einklappen - Hide Extra Bookmarks — Boards ab 5/10/20 Bookmarks einklappen
- Hintergrundbild — URL oder lokaler Upload
- Suchleiste ein/ausblenden - Suchleiste ein/ausblenden
- JSON Export / Import - JSON Export / Import
- Onboarding wiederholbar
- Danger Zone — Reset aller Daten - Danger Zone — Reset aller Daten
### Theme-Picker (eigener Header-Button)
- 8 Themes als zentriertes Modal
- Hintergrundbild per URL oder lokaler Upload
--- ---
## Browser-Kompatibilität ## Browser-Kompatibilität
| Browser | Status | Manifest | | Browser | Status | Manifest |
| --- | --- | --- | |---|---|---|
| Chrome | Kompatibel | V3 (`manifest.json`) | | Chrome | Kompatibel | V3 (`manifest.json`) |
| Edge | Kompatibel | V3 (`manifest.json`) | | Edge | Kompatibel | V3 (`manifest.json`) |
| Brave | Kompatibel | V3 (`manifest.json`) | | Brave | Kompatibel | V3 (`manifest.json`) |
| Opera | Kompatibel | V3 (`manifest.json`) | | Opera | Kompatibel | V3 (`manifest.opera.json`) |
| Opera GX | Kompatibel | V3 (`manifest.json`) | | Opera GX | Kompatibel | V3 (`manifest.opera.json`) |
| Vivaldi | Kompatibel | V3 (`manifest.json`) | | Vivaldi | Kompatibel | V3 (`manifest.json`) |
| Firefox | Kompatibel | V2 (`manifest.firefox.json`) | | Firefox | Kompatibel | V3 (`manifest.firefox.json`) |
> **Firefox-Hinweis:** Firefox verwendet aktuell Manifest V2. Mozilla arbeitet an MV3-Support — > **Firefox-Hinweis:** Ab v1.2.0 läuft die Extension auf Manifest V3 — identisch zu Chrome/Edge.
> sobald stabil, wird die Extension migriert. MV2 wird von Mozilla weiterhin unterstützt. > `manifest.firefox.json` bleibt als separate Datei erhalten für Firefox-spezifische Anpassungen.
--- ---
## Installation ## Installation
### Chrome / Edge / Brave / Opera / Opera GX / Vivaldi ### Chrome / Edge / Brave / Vivaldi
```text ```text
1. Repository als ZIP herunterladen oder git clone 1. Repository als ZIP herunterladen oder git clone
2. chrome://extensions öffnen (oder edge:// / brave:// / opera://) 2. chrome://extensions öffnen (bzw. edge:// / brave://)
3. Entwicklermodus aktivieren 3. Entwicklermodus aktivieren
4. "Entpackte Erweiterung laden" → Ordner auswählen in dem manifest.json liegt 4. "Entpackte Erweiterung laden" → Ordner auswählen in dem manifest.json liegt
5. Neuen Tab öffnen 5. Neuen Tab öffnen
``` ```
### Firefox ### Opera / Opera GX
Firefox benötigt `manifest.json` im Format V2.
```bash ```bash
# manifest.json durch Firefox-Version ersetzen: # manifest.opera.json als manifest.json verwenden:
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
```
> **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)
### Firefox
```bash
# manifest.firefox.json als manifest.json verwenden:
copy manifest.firefox.json manifest.json # Windows copy manifest.firefox.json manifest.json # Windows
cp manifest.firefox.json manifest.json # Linux/Mac cp manifest.firefox.json manifest.json # Linux/Mac
``` ```
@@ -144,7 +173,7 @@ cp manifest.firefox.json manifest.json # Linux/Mac
## Browser-Bookmarks exportieren & importieren ## Browser-Bookmarks exportieren & importieren
| Browser | Export-Pfad | | Browser | Export-Pfad |
| --- | --- | |---|---|
| Chrome / Edge | Einstellungen → Lesezeichen → Exportieren | | Chrome / Edge | Einstellungen → Lesezeichen → Exportieren |
| Firefox | Lesezeichen → Alle Lesezeichen → Importieren und Sichern → Als HTML exportieren | | Firefox | Lesezeichen → Alle Lesezeichen → Importieren und Sichern → Als HTML exportieren |
@@ -166,11 +195,11 @@ Die exportierte `.html`-Datei über den **Import**-Button in der Extension laden
## Tech-Stack ## Tech-Stack
| Komponente | Details | | Komponente | Details |
| --- | --- | |---|---|
| Sprache | JavaScript (Vanilla ES2020, keine Frameworks) | | Sprache | JavaScript (Vanilla ES2020, keine Frameworks) |
| Styling | CSS Custom Properties (Theme-System) | | Styling | CSS Custom Properties (Theme-System) |
| Fonts | Google Fonts (Rajdhani, Inter, Cinzel) | | Fonts | Lokale Fonts (Rajdhani, Inter, Cinzel) |
| Storage | chrome.storage.local / localStorage Fallback | | Storage | `chrome.storage.local` / `localStorage` Fallback |
| Favicons | Google Favicons API (`/s2/favicons`) | | Favicons | Google Favicons API (`/s2/favicons`) |
| Drag & Drop | Pointer Events API (nativ) | | Drag & Drop | Pointer Events API (nativ) |
| Build | Kein Build-Schritt — direkt lauffähig | | Build | Kein Build-Schritt — direkt lauffähig |
@@ -182,10 +211,12 @@ Die exportierte `.html`-Datei über den **Import**-Button in der Extension laden
```text ```text
hellion-newtab/ hellion-newtab/
├── manifest.json # Chrome, Edge, Brave, Opera, Vivaldi (MV3) ├── manifest.json # Chrome, Edge, Brave, Vivaldi (MV3)
├── manifest.firefox.json # Firefox (MV2) ├── manifest.firefox.json # Firefox (MV3)
├── manifest.opera.json # Opera / Opera GX (MV3 + Workaround)
├── newtab.html # Haupt-HTML (UI-Struktur, Modals, Settings Panel) ├── newtab.html # Haupt-HTML (UI-Struktur, Modals, Settings Panel)
├── LICENSE # CC BY-NC-SA 4.0 ├── LICENSE # CC BY-NC-SA 4.0
├── CHANGELOG.md # Versionshistorie
├── SECURITY.md # Sicherheitsrichtlinie und Meldeprozess ├── SECURITY.md # Sicherheitsrichtlinie und Meldeprozess
├── DISCLAIMER.md # Haftungsausschluss ├── DISCLAIMER.md # Haftungsausschluss
@@ -193,27 +224,25 @@ hellion-newtab/
│ ├── js/ │ ├── js/
│ │ ├── storage.js # Storage Abstraction + Quota-Prüfung │ │ ├── storage.js # Storage Abstraction + Quota-Prüfung
│ │ ├── state.js # Globaler State, Defaults, Hilfsfunktionen │ │ ├── state.js # Globaler State, Defaults, Hilfsfunktionen
│ │ ├── dialog.js # Custom Dialog-System (HellionDialog.alert/confirm)
│ │ ├── themes.js # Theme-Definitionen & Anwendungslogik │ │ ├── themes.js # Theme-Definitionen & Anwendungslogik
│ │ ├── boards.js # Board/Bookmark Rendering, Event Delegation, Modals │ │ ├── boards.js # Board/Bookmark Rendering, Event Delegation, Modals
│ │ ├── drag.js # Drag & Drop (Pointer Events, Board + Bookmark) │ │ ├── drag.js # Drag & Drop (Pointer Events, Board + Bookmark)
│ │ ├── settings.js # Settings Panel (Toggles, Theme-Picker, Background) │ │ ├── settings.js # Settings Panel, Theme-Modal, Accordion
│ │ ├── search.js # Suchleiste (Google, DuckDuckGo, Bing) │ │ ├── search.js # Suchleiste (Google, DuckDuckGo, Bing)
│ │ ├── sticky.js # Sticky Note Widget (Drag, Persist, Toggle) │ │ ├── sticky.js # Sticky Note Widget (Drag, Persist, Toggle)
│ │ ├── data.js # JSON Export / Import mit Validierung │ │ ├── data.js # JSON Export / Import mit Validierung
│ │ ── app.js # Init, Clock, globale Events (Einstiegspunkt) │ │ ── 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
│ └── css/ │ └── css/
│ └── main.css # Styles + Theme-System + Responsive Breakpoints │ └── main.css # Styles + Theme-System + Responsive Breakpoints
├── assets/ ├── assets/
│ ├── fonts/ # Lokale Fonts (Rajdhani, Inter, Cinzel)
│ ├── themes/ # 8 Theme-Hintergrundbilder │ ├── themes/ # 8 Theme-Hintergrundbilder
│ │ ├── bg-nebula.jpg
│ │ ├── bg-crescent.jpg
│ │ ├── bg-event-horizon.jpg
│ │ ├── bg-merchantman.webp
│ │ ├── bg-julia-jin.png
│ │ ├── bg-sc-sunset.jpg
│ │ ├── bg-hellion-hud.png
│ │ └── bg-hellion-energy.jpg
│ └── icons/ │ └── icons/
│ ├── icon16.png │ ├── icon16.png
│ ├── icon48.png │ ├── icon48.png
@@ -223,16 +252,16 @@ hellion-newtab/
└── workflows/ └── workflows/
├── security.yml # CodeQL-Analyse + Dependency Review ├── security.yml # CodeQL-Analyse + Dependency Review
├── quality.yml # Struktur, Manifest, Syntax, Versions-Konsistenz ├── quality.yml # Struktur, Manifest, Syntax, Versions-Konsistenz
└── release.yml # ZIP-Pakete (Chrome + Firefox) + SHA256 Checksummen └── release.yml # ZIP-Pakete (Chrome + Firefox + Opera) + SHA256
``` ```
### Design-Prinzipien ### Design-Prinzipien
- **Zero Dependencies** — Kein npm, kein Build, kein Framework. Direkt lauffähig - **Zero Dependencies** — Kein npm, kein Build, kein Framework. Direkt lauffähig
- **Privacy First** — Alle Daten lokal, kein Server-Kontakt - **Privacy First** — Alle Daten lokal, kein Server-Kontakt
- **Modular** — 10 JS-Dateien mit klarer Zuständigkeit - **Modular** — 12 JS-Dateien mit klarer Zuständigkeit
- **Responsive** — Tablet (768px) und Smartphone (480px) Breakpoints - **Responsive** — Tablet (768px) und Smartphone (480px) Breakpoints
- **Secure** — createElement statt innerHTML, URL-Validierung, Storage-Fehlerbehandlung - **Secure** — `createElement` statt `innerHTML`, URL-Validierung, Storage-Fehlerbehandlung
- **Event Delegation** — Ein Listener pro Board-Liste statt pro Bookmark (Performance) - **Event Delegation** — Ein Listener pro Board-Liste statt pro Bookmark (Performance)
- **Theme-System** — CSS Custom Properties, 8 Themes, Custom-Background-Support - **Theme-System** — CSS Custom Properties, 8 Themes, Custom-Background-Support
@@ -257,14 +286,14 @@ hellion-newtab/
### Release (`release.yml`) ### Release (`release.yml`)
- **Trigger** — Bei Git-Tag (`v*`) - **Trigger** — Bei Git-Tag (`v*`)
- **Pakete** — Chrome-ZIP (MV3) + Firefox-ZIP (MV2) - **Pakete** — Chrome-ZIP + Firefox-ZIP + Opera-ZIP (alle MV3)
- **Checksummen** — SHA256 für alle Artefakte - **Checksummen** — SHA256 für alle Artefakte
- **GitHub Release** — Automatisch mit Installationsanleitung - **GitHub Release** — Automatisch mit Installationsanleitung
```bash ```bash
# Release erstellen: # Release erstellen:
git tag v1.2.0 git tag v1.5.2
git push origin v1.2.0 git push origin v1.5.2
# → GitHub Action erstellt automatisch Release mit ZIP-Dateien # → GitHub Action erstellt automatisch Release mit ZIP-Dateien
``` ```
@@ -304,57 +333,14 @@ Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
Vollständige Lizenz: [LICENSE](LICENSE) | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) Vollständige Lizenz: [LICENSE](LICENSE) | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/)
| | | | | |
| --- | --- | |---|---|
| **Entwickler** | Florian Wathling | | **Entwickler** | Florian Wathling |
| **Unternehmen** | Hellion Online Media | | **Unternehmen** | Hellion Online Media |
| **Web** | [hellion-media.de](https://hellion-media.de) | | **Web** | [hellion-media.de](https://hellion-media.de) |
| **Impressum** | [hellion-media.de/impressum](https://hellion-media.de/impressum) | | **Impressum** | [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) | | **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) | | **Security** | [SECURITY.md](SECURITY.md) |
| **Support** | [Ko-fi](https://ko-fi.com/hellionmedia) |
---
## Changelog
### 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 V2)
- 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
### 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
### 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
--- ---
@@ -364,4 +350,6 @@ AI (Claude Code, Opus 4.6 von Anthropic) wurde als Hilfsmittel eingesetzt — f
--- ---
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion > Vollständige Versionshistorie: [CHANGELOG.md](CHANGELOG.md)
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
+1 -1
View File
@@ -73,4 +73,4 @@ Keine Permissions für: Tabs, History, Web Requests, Downloads, Clipboard oder H
--- ---
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion **Hellion Dashboard** — [Hellion Online Media - Florian Wathling](https://hellion-media.de) — JonKazama-Hellion
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 B

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 B

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 B

After

Width:  |  Height:  |  Size: 3.8 KiB

+163
View File
@@ -0,0 +1,163 @@
# Hellion Dashboard — Architecture
## 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 for Chromium browsers, V3 for Firefox (separate manifest).
---
## 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, themes, responsive breakpoints
│ └── js/
│ ├── storage.js # Storage abstraction layer
│ ├── state.js # Global state, defaults, helpers
│ ├── themes.js # Theme definitions & application
│ ├── boards.js # Board/bookmark rendering & events
│ ├── drag.js # Drag & drop (Pointer Events API)
│ ├── 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)
│ ├── onboarding.js # First-run onboarding flow
│ ├── data.js # JSON export/import (backup & restore)
│ ├── app.js # Init, clock, global events (entry point)
│ └── dialog.js # Custom dialog system (alert, confirm)
├── assets/
│ ├── icons/ # Extension icons (16-512px)
│ └── themes/ # Theme background images
└── docs/ # Documentation (you are here)
```
---
## Module Responsibilities
Each module has exactly one responsibility. They communicate through global references (no import/export — this is a browser extension without a bundler).
| Module | Responsibility |
|---|---|
| `storage.js` | **Only** place that touches `chrome.storage` / `localStorage`. All other modules go through `Store.get()` / `Store.set()`. |
| `state.js` | Global `boards` and `settings` arrays, default values, `uid()`, `escHtml()`, `getFaviconUrl()`. |
| `themes.js` | Theme CSS variable application. 8 themes, each with its own `[data-theme]` block in 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, theme 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. |
| `image-ref.js` | Image reference widget. Multi-instance (max 3). Canvas API WebP conversion, sessionStorage for image data. |
| `onboarding.js` | Multi-slide onboarding flow. Gaming starter board opt-in. |
| `data.js` | JSON export/import with validation. Handles boards, notes, calculator history, timer presets. |
| `app.js` | Entry point. Calls `init()` on DOMContentLoaded. Clock, global event binding. |
| `dialog.js` | `HellionDialog.alert()` and `HellionDialog.confirm()` — custom styled dialogs replacing native browser dialogs. |
---
## 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
→ 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:
```html
<script src="src/js/dialog.js"></script>
<script src="src/js/storage.js"></script>
<script src="src/js/state.js"></script>
<script src="src/js/themes.js"></script>
<script src="src/js/boards.js"></script>
<script src="src/js/drag.js"></script>
<script src="src/js/settings.js"></script>
<script src="src/js/search.js"></script>
<script src="src/js/onboarding.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/data.js"></script>
<script src="src/js/app.js"></script>
```
**Rule:** A module may only reference modules loaded before it.
---
## 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 incrementing z-index (`WidgetManager._topZ++`) to stack above each other on click.
---
## 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 onboarding has been completed |
| `lastBackupReminder` | Number | Timestamp of 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` |
Changes affecting manifest fields must be synchronized across all three manifest files.
+310
View File
@@ -0,0 +1,310 @@
# Hellion Dashboard — Code Patterns & Conventions
## 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.
```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();
```
**Why?** The `Store` handles the chrome.storage / localStorage fallback transparently. It also provides unified error handling (shows a dialog when storage is full).
---
## Pattern: Event Delegation
Instead of attaching listeners to each element, attach one to the container and use `closest()` to find the target.
```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), `calculator.js` (button grid)
---
## Pattern: createElement over innerHTML
Always build DOM with `document.createElement()`. This prevents XSS and is the project's #1 security rule.
```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
Multiple widget modules share the `widgetStates` key. Every module must read-before-write and preserve other modules' data.
```javascript
async save() {
const data = await Store.get('widgetStates') || {};
// Write your own data
data.yourKey = { /* ... */ };
// DON'T overwrite — the key already contains other modules' data
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 know when they're closed, minimized, or reopened. They wrap `WidgetManager` methods in their `init()`:
```javascript
async init() {
// Wrap close
const prevClose = WidgetManager.close;
const self = this;
WidgetManager.close = function(id) {
prevClose.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self.onClose();
}
};
// Wrap minimize
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();
}
};
}
```
**Important:** Multiple widgets chain these wraps. Calculator wraps first, Timer wraps Calculator's already-wrapped version, and so on. The chain must not break.
---
## Pattern: Debounced Save
For frequent updates (typing in notes, moving widgets), use debounced saves to avoid excessive storage writes:
```javascript
_saveTimer: null,
_debouncedSave() {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => this.save(), 500);
}
// Usage: call _debouncedSave() instead of save() for frequent events
textarea.addEventListener('input', () => {
noteData.content = textarea.value;
this._debouncedSave();
});
```
**Used in:** `notes.js` (text editing), `image-ref.js` (label editing)
---
## Pattern: Theme System
All themes use CSS Custom Properties defined in `[data-theme="name"]` blocks:
```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.** Use CSS classes or variables:
```javascript
// GOOD — let CSS handle colors
element.classList.add('active');
// BAD — hardcoded color
element.style.color = '#7db3ff';
```
8 themes are available: Nebula, Crescent, Event Horizon, Merchantman, Julia & Jin, SC Sunset, Hellion HUD, Hellion Energy.
---
## Pattern: Onboarding Slides
The onboarding system (`onboarding.js`) uses a data-driven slide array. Each slide is an object with rendering hints:
```javascript
{
hero: '🎮', // Large emoji/icon
title: 'Slide Title', // Heading
text: 'Description...', // Optional text paragraph
features: ['Item 1', ...], // Optional bullet list
showThemes: true, // Optional theme grid
interactive: 'gaming-board' // Optional custom buttons
}
```
The `_render()` method reads these properties and builds the DOM. To add a new slide, just add an object to the `slides` array.
---
## Pattern: Dialog System
Custom dialogs replace native `alert()` and `confirm()`:
```javascript
// Alert (informational)
await HellionDialog.alert('Message text', {
type: 'info', // 'info', 'success', 'warning', 'danger'
title: 'Title'
});
// Confirm (yes/no)
const ok = await HellionDialog.confirm('Are you sure?', {
type: 'danger',
title: 'Delete',
confirmText: 'Delete', // Custom button text
cancelText: 'Cancel'
});
if (ok) { /* user confirmed */ }
```
---
## Pattern: Pointer Events for Drag
Widget dragging and board reordering use the Pointer Events API (not mouse events):
```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);
});
```
**Why Pointer Events over Mouse Events?** They work with both mouse and touch, and `setPointerCapture` ensures events continue even if the cursor leaves the element.
---
## Pattern: Canvas API Image Processing
The image reference widget converts uploaded images to WebP for smaller size:
```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);
resolve(webpUrl);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Image could not be loaded'));
};
img.src = objectUrl;
});
}
```
**Important:** Always call `URL.revokeObjectURL()` to free memory.
---
## Coding Rules Summary
| Rule | Rationale |
|---|---|
| `createElement` only, never `innerHTML` | XSS prevention |
| All storage through `Store` | Browser compatibility |
| CSS variables, no hardcoded colors | Theme support |
| Event delegation | Performance, dynamic content |
| `const`/`let`, never `var` | Block scoping |
| No external dependencies | Extension simplicity |
| No build step | Direct development |
| JSDoc comments on public functions | Documentation |
| URL validation before `href` | Security |
| Error handling on storage operations | Graceful failure |
---
## Manifest Synchronization
Three manifest files must stay in sync:
- `manifest.json` — Chrome, Edge, Brave, Vivaldi
- `manifest.firefox.json` — Firefox
- `manifest.opera.json` — Opera, Opera GX
When changing version numbers, permissions, or content script entries, update all three files.
+330
View File
@@ -0,0 +1,330 @@
# Hellion Dashboard — Widget Schema
## 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.
---
## 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.
```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 for content rendering.
```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.
### `minimize(id)`
Hides a widget with animation. Widget remains in registry with `open: false`.
### `openWidget(id)`
Restores a minimized widget with animation.
### `bringToFront(id)`
Increments z-index to bring widget above all others.
### `save() → Array`
Returns an array of all `type: 'note'` widget states. Used by `Notes.save()` to merge with note content data.
### `restore(renderCallback)`
Loads widget states from storage and recreates all note widgets. Only handles notes — single-instance widgets (calculator, timer) restore themselves in their own `init()`.
---
## Shared Storage Key: `widgetStates`
All widget modules share a single storage key. Each module's `save()` method must preserve other modules' data.
```javascript
// Structure of widgetStates
{
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: [], // For 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
}
]
}
}
```
### Save Pattern — Preserving Other Modules' Data
Every module that saves to `widgetStates` must read existing data first and preserve keys it doesn't own:
```javascript
// Example from notes.js
async save() {
const existing = await Store.get(this.STORAGE_KEY);
const saveData = { notes: mergedNotes };
// Preserve other modules
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: Choose Single or Multi-Instance
- **Single-instance** (like Calculator, Timer): One widget with a fixed ID. `toggle()` opens/closes.
- **Multi-instance** (like Notes, ImageRef): Multiple widgets with dynamic IDs. `create()` adds new ones.
### Step 2: Create the Module (`src/js/your-widget.js`)
```javascript
const YourWidget = {
WIDGET_ID: 'widget_yourwidget', // Fixed ID for single-instance
STORAGE_KEY: 'widgetStates',
_isOpen: false,
// Load state from storage
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.yourWidget) {
// Restore your state
}
},
// Save state, preserving other modules
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);
},
// Open widget
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();
},
// Toggle open/close
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();
}
},
// Render widget content
renderBody(bodyEl) {
bodyEl.textContent = '';
// Build your UI with createElement (never innerHTML!)
},
// Initialize and hook into lifecycle
async init() {
await this.load();
// Restore if was open last time
const data = await Store.get(this.STORAGE_KEY);
if (data && data.yourWidget && data.yourWidget.open) {
await this.open();
}
// Hook into close event
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();
}
};
// Hook into minimize event
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();
}
};
// Hook into open event
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`, before `data.js`)
2. **`newtab.html`** — Add toolbar button: `<button class="widget-toolbar-btn" data-action="your-action">`
3. **`notes.js`** — Add toolbar handler in `initToolbar()`: `} else if (action === 'your-action') { YourWidget.toggle(); }`
4. **`notes.js`** — Preserve your data in `save()`: `if (existing && existing.yourWidget) saveData.yourWidget = existing.yourWidget;`
5. **`app.js`** — Add `await YourWidget.init();` to the init sequence
6. **`src/css/main.css`** — Add widget-specific CSS styles
7. **`data.js`** — Add export/import logic (if data should be included in backups)
---
## Widget DOM Structure
Every widget created by `WidgetManager.create()` has this structure:
```html
<div class="widget" data-widget-id="widget_abc123"
style="left: 120px; top: 80px; width: 280px; height: 220px;">
<div class="widget-header">
<span class="widget-title">Title</span>
<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>
</div>
```
- **Header** is the drag handle (Pointer Events)
- **Title** supports double-click to edit (contentEditable, max 20 chars)
- **Body** is where your module renders content
- **Resize handle** appears on hover (bottom-right corner)
+20 -5
View File
@@ -1,26 +1,41 @@
{ {
"manifest_version": 2, "manifest_version": 3,
"name": "Hellion NewTab", "name": "Hellion NewTab",
"version": "1.2.0", "version": "1.9.0",
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.", "description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
"author": "Florian Wathling hellion-media.de", "author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de", "homepage_url": "https://hellion-media.de",
"chrome_url_overrides": { "chrome_url_overrides": {
"newtab": "newtab.html" "newtab": "newtab.html"
}, },
"permissions": [ "permissions": [
"storage", "storage",
"bookmarks" "bookmarks"
], ],
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "hellion-newtab@hellion-media.de", "id": "hellion-newtab@hellion-media.de",
"strict_min_version": "109.0" "update_url": "https://hellion-media.de/extensions/firefox/updates.json",
"strict_min_version": "142.0",
"data_collection_permissions": {
"required": [
"none"
]
}
} }
}, },
"web_accessible_resources": [
{
"resources": ["assets/fonts/*.woff2"],
"matches": ["<all_urls>"]
}
],
"icons": { "icons": {
"16": "assets/icons/icon16.png", "16": "assets/icons/icon16.png",
"48": "assets/icons/icon48.png", "48": "assets/icons/icon48.png",
"128": "assets/icons/icon128.png" "128": "assets/icons/icon128.png"
} }
} }
+8 -2
View File
@@ -1,9 +1,9 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Hellion NewTab", "name": "Hellion NewTab",
"version": "1.2.0", "version": "1.9.0",
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.", "description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
"author": "Florian Wathling hellion-media.de", "author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de", "homepage_url": "https://hellion-media.de",
"chrome_url_overrides": { "chrome_url_overrides": {
"newtab": "newtab.html" "newtab": "newtab.html"
@@ -12,6 +12,12 @@
"storage", "storage",
"bookmarks" "bookmarks"
], ],
"web_accessible_resources": [
{
"resources": ["assets/fonts/*.woff2"],
"matches": ["<all_urls>"]
}
],
"icons": { "icons": {
"16": "assets/icons/icon16.png", "16": "assets/icons/icon16.png",
"48": "assets/icons/icon48.png", "48": "assets/icons/icon48.png",
+47
View File
@@ -0,0 +1,47 @@
{
"manifest_version": 3,
"name": "Hellion Dashboard (GX Native)",
"version": "1.9.0",
"description": "Ersetzt die Opera GX Startseite durch dein persönliches, leistungsoptimiertes Hellion Dashboard. Schnell, sauber und werbefrei.",
"author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de",
"permissions": [
"tabs",
"storage",
"bookmarks"
],
"background": {
"service_worker": "src/js/opera/background.js"
},
"content_scripts": [
{
"matches": [
"https://*.opera.com/startpage*",
"http://*.opera.com/startpage*"
],
"js": ["src/js/opera/redirect.js"],
"run_at": "document_start",
"all_frames": true
}
],
"web_accessible_resources": [
{
"resources": ["assets/fonts/*.woff2"],
"matches": ["<all_urls>"]
}
],
"action": {
"default_title": "Hellion Dashboard"
},
"icons": {
"16": "assets/icons/icon16.png",
"48": "assets/icons/icon48.png",
"128": "assets/icons/icon128.png"
}
}
+308 -235
View File
@@ -3,10 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hellion NewTab</title> <title>Hellion Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Inter:wght@300;400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="src/css/main.css" /> <link rel="stylesheet" href="src/css/main.css" />
</head> </head>
<body> <body>
@@ -38,6 +35,10 @@
<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> <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 Note
</button> </button>
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme">
<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>
Darstellung
</button>
<button class="btn-icon" id="btnSettings" title="Einstellungen"> <button class="btn-icon" id="btnSettings" title="Einstellungen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Settings Settings
@@ -58,255 +59,295 @@
</div> </div>
</div> </div>
<!-- STICKY NOTE --> <!-- WIDGET TOOLBAR -->
<div class="sticky-note" id="stickyNote"> <div class="widget-toolbar" id="widgetToolbar">
<div class="sticky-note-header" id="stickyNoteHeader"> <button class="widget-toolbar-btn" data-action="new-note" title="Note erstellen">
<span class="sticky-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>
<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> </button>
Note <button class="widget-toolbar-btn" data-action="new-checklist" title="Checkliste erstellen">
</span> <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 class="sticky-note-close" id="stickyNoteClose"></button> </button>
</div> <button class="widget-toolbar-btn" data-action="calculator" title="Taschenrechner">
<textarea class="sticky-note-body" id="stickyNoteBody" placeholder="Quick note…" spellcheck="false"></textarea> <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" 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" 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" 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> </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">Notebook <span class="notebook-count" id="notebookCount">0 / 5</span></span>
<button class="btn-close" id="btnCloseNotebook"></button>
</div>
<div class="notebook-slots" id="notebookSlots">
<!-- dynamisch via JS -->
</div>
</aside>
<!-- BOARDS CONTAINER --> <!-- BOARDS CONTAINER -->
<main class="boards-wrapper" id="boardsWrapper"> <main class="boards-wrapper" id="boardsWrapper">
<!-- dynamisch via JS --> <!-- dynamisch via JS -->
</main> </main>
<!-- HIDDEN FILE INPUT FOR IMPORT --> <!-- HIDDEN FILE INPUT FOR IMPORT -->
<input type="file" id="importInput" accept=".html,.htm" style="display:none" /> <input type="file" id="importInput" accept=".html,.htm" class="hidden" />
<!-- SETTINGS PANEL --> <!-- SETTINGS PANEL -->
<div class="panel-overlay" id="settingsOverlay"></div> <div class="panel-overlay" id="settingsOverlay"></div>
<aside class="settings-panel" id="settingsPanel"> <aside class="settings-panel" id="settingsPanel">
<div class="panel-header"> <div class="panel-header">
<span>Settings</span> <span>Einstellungen</span>
<button class="btn-close" id="btnCloseSettings"></button> <button class="btn-close" id="btnCloseSettings"></button>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<!-- APPEARANCE --> <!-- WIDGETS -->
<section class="settings-section"> <section class="settings-section" data-section="widgets">
<!-- THEME PICKER --> <button class="settings-section-title" type="button">
<h3 class="settings-section-title">THEME</h3> <span class="section-chevron"></span>
<div class="theme-grid"> WIDGETS
<div class="theme-card active" data-value="nebula"> </button>
<img class="theme-card-img" src="assets/themes/bg-nebula.jpg" alt="Nebula" /> <div class="section-content">
<span class="theme-card-label">Nebula</span> <div class="setting-row">
<span class="theme-card-check"></span> <div class="setting-info">
<span class="setting-label">Toolbar-Position</span>
<span class="setting-desc">Widget-Toolbar links oder rechts anzeigen</span>
</div>
<select class="select-input" id="settingToolbarPos">
<option value="right" selected>Rechts</option>
<option value="left">Links</option>
</select>
</div> </div>
<div class="theme-card" data-value="crescent"> <div class="setting-row">
<img class="theme-card-img" src="assets/themes/bg-crescent.jpg" alt="Crescent" /> <div class="setting-info">
<span class="theme-card-label">Crescent</span> <span class="setting-label">Bild-Referenz Widgets</span>
<span class="theme-card-check"></span> <span class="setting-desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
</div> </div>
<div class="theme-card" data-value="event-horizon"> <label class="toggle">
<img class="theme-card-img" src="assets/themes/bg-event-horizon.jpg" alt="Event Horizon" /> <input type="checkbox" id="settingImageRef">
<span class="theme-card-label">Event Horizon</span> <span class="slider"></span>
<span class="theme-card-check"></span> </label>
</div>
<div class="theme-card" data-value="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 & 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" />
<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" />
<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" />
<span class="theme-card-label">Energy</span>
<span class="theme-card-check"></span>
</div> </div>
</div> </div>
</section>
<h3 class="settings-section-title">APPEARANCE</h3> <!-- DATEN & HILFE -->
<section class="settings-section" data-section="data">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
DATEN & HILFE
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Backup exportieren</span>
<span class="setting-desc">Alle Boards, Notes und Einstellungen als JSON sichern</span>
</div>
<button class="btn-small" id="btnExportJSON">Export</button>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Backup importieren</span>
<span class="setting-desc">JSON-Backup wiederherstellen</span>
</div>
<button class="btn-small" id="btnImportJSON">Import</button>
<input type="file" id="jsonImportInput" accept=".json" class="hidden" />
</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>
</div>
<button class="btn-small" id="btnRestartOnboarding">Start</button>
</div>
</div>
</section>
<!-- DANGER ZONE -->
<section class="settings-section" data-section="danger">
<button class="settings-section-title danger" type="button">
<span class="section-chevron"></span>
DANGER ZONE
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Alles zurücksetzen</span>
<span class="setting-desc">Löscht alle Boards, Notes und Einstellungen</span>
</div>
<button class="btn-danger" id="btnResetAll">Reset</button>
</div>
</div>
</section>
</div>
<!-- ABOUT — fixiert am unteren Rand -->
<div class="panel-footer">
<div class="about-block">
<div class="about-logo">⬡ HELLION NEWTAB</div>
<div class="about-version">Version 1.9.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>
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-bugreport">
<span class="about-info-label about-info-label-block">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">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="modal-header">
<span>Darstellung</span>
<button class="btn-close" id="btnCloseTheme"></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" />
<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" />
<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" />
<span class="theme-card-label">Event Horizon</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="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" />
<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" />
<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" />
<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" />
<span class="theme-card-label">Energy</span>
<span class="theme-card-check"></span>
</div>
</div>
<div class="theme-modal-section">
<h3 class="settings-section-title">HINTERGRUND</h3>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<span class="setting-label">Compact mode</span> <span class="setting-label">Bild-URL</span>
<span class="setting-desc">Reduce spacing to show more bookmarks</span> <span class="setting-desc">Eigenes Hintergrundbild per URL</span>
</div>
<button class="btn-small" id="btnChangeBg">Ändern</button>
</div>
<div class="setting-row hidden" id="bgInputRow">
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... oder leer für Standard" />
<button class="btn-small" id="btnApplyBg">Übernehmen</button>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Datei hochladen</span>
<span class="setting-desc">Lokales Bild als Hintergrund verwenden</span>
</div>
<button class="btn-small" id="btnBgFile">Upload</button>
<input type="file" id="bgFileInput" accept="image/*" class="hidden" />
</div>
</div>
<div class="theme-modal-section">
<h3 class="settings-section-title">DARSTELLUNG</h3>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Kompaktmodus</span>
<span class="setting-desc">Weniger Abstand für mehr Bookmarks</span>
</div> </div>
<label class="toggle"><input type="checkbox" id="settingCompact" /><span class="slider"></span></label> <label class="toggle"><input type="checkbox" id="settingCompact" /><span class="slider"></span></label>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<span class="setting-label">Shorten long titles</span> <span class="setting-label">Lange Titel kürzen</span>
<span class="setting-desc">Shorten title to one line with "…"</span> <span class="setting-desc">Titel auf eine Zeile mit „…" kürzen</span>
</div> </div>
<label class="toggle"><input type="checkbox" id="settingShorten" /><span class="slider"></span></label> <label class="toggle"><input type="checkbox" id="settingShorten" /><span class="slider"></span></label>
</div> </div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Background image</span>
<span class="setting-desc">Custom wallpaper URL or upload</span>
</div>
<button class="btn-small" id="btnChangeBg">Change</button>
</div>
<div class="setting-row" id="bgInputRow" style="display:none">
<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>
</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>
</div>
<button class="btn-small" id="btnBgFile">Upload</button>
<input type="file" id="bgFileInput" accept="image/*" style="display:none" />
</div>
</section>
<!-- BEHAVIOR -->
<section class="settings-section">
<h3 class="settings-section-title">BEHAVIOR</h3>
<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>
</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>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Quick Save shortcut</span>
<span class="setting-desc">Save current page to a board quickly</span>
</div>
<span class="setting-badge" id="quickSaveBadge">Not set</span>
</div>
</section>
<!-- ABOUT / IMPRESSUM -->
<section class="settings-section">
<h3 class="settings-section-title">ABOUT</h3>
<div class="about-block">
<div class="about-logo">⬡ HELLION NEWTAB</div>
<div class="about-version">Version 1.2.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>
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" style="color:var(--text-secondary);text-decoration:none">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" style="display:block;margin-bottom:6px">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" style="display:block;margin-bottom:6px">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>
</section>
<!-- DATA -->
<section class="settings-section">
<h3 class="settings-section-title">DATA</h3>
<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>
</div>
<button class="btn-small" id="btnExportJSON">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>
</div>
<button class="btn-small" id="btnImportJSON">Import</button>
<input type="file" id="jsonImportInput" accept=".json" style="display:none" />
</div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<span class="setting-label">Suchleiste anzeigen</span> <span class="setting-label">Suchleiste anzeigen</span>
@@ -314,22 +355,41 @@
</div> </div>
<label class="toggle"><input type="checkbox" id="settingShowSearch" checked /><span class="slider"></span></label> <label class="toggle"><input type="checkbox" id="settingShowSearch" checked /><span class="slider"></span></label>
</div> </div>
</section>
<!-- DANGER ZONE -->
<section class="settings-section">
<h3 class="settings-section-title danger">DANGER ZONE</h3>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<span class="setting-label">Reset all data</span> <span class="setting-label">Links in neuem Tab</span>
<span class="setting-desc">Deletes all boards and bookmarks</span> <span class="setting-desc">Bookmarks in neuem Browser-Tab öffnen</span>
</div> </div>
<button class="btn-danger" id="btnResetAll">Reset</button> <label class="toggle"><input type="checkbox" id="settingNewTab" checked /><span class="slider"></span></label>
</div> </div>
</section> <div class="setting-row">
<div class="setting-info">
<span class="setting-label">Beschreibungen anzeigen</span>
<span class="setting-desc">Gespeicherte Beschreibung unter Bookmarks</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">Bookmarks ausblenden</span>
<span class="setting-desc">Überzählige Bookmarks in langen Boards verstecken</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">Sichtbare Bookmarks</span>
<span class="setting-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>
</aside> </div>
<!-- ADD BOARD MODAL --> <!-- ADD BOARD MODAL -->
<div class="modal-overlay" id="addBoardOverlay"> <div class="modal-overlay" id="addBoardOverlay">
@@ -356,8 +416,8 @@
</div> </div>
<div class="modal-body"> <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" placeholder="Title..." maxlength="60" />
<input type="url" class="text-input full-width" id="newBmUrl" placeholder="https://..." style="margin-top:8px" /> <input type="url" class="text-input full-width modal-input-spaced" id="newBmUrl" placeholder="https://..." />
<input type="text" class="text-input full-width" id="newBmDesc" placeholder="Description (optional)" style="margin-top:8px" /> <input type="text" class="text-input full-width modal-input-spaced" id="newBmDesc" placeholder="Description (optional)" />
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn-primary" id="btnConfirmBookmark">Add</button> <button class="btn-primary" id="btnConfirmBookmark">Add</button>
@@ -381,10 +441,17 @@
</div> </div>
</div> </div>
<!-- ONBOARDING -->
<div class="dialog-overlay" id="onboardingOverlay">
<div class="onboarding-modal" id="onboardingModal"></div>
</div>
<!-- Storage muss zuerst --> <!-- Storage muss zuerst -->
<script src="src/js/storage.js"></script> <script src="src/js/storage.js"></script>
<!-- State & Hilfsfunktionen --> <!-- State & Hilfsfunktionen -->
<script src="src/js/state.js"></script> <script src="src/js/state.js"></script>
<!-- Dialog-System (vor Features, wird überall gebraucht) -->
<script src="src/js/dialog.js"></script>
<!-- Theme-System --> <!-- Theme-System -->
<script src="src/js/themes.js"></script> <script src="src/js/themes.js"></script>
<!-- Features --> <!-- Features -->
@@ -392,8 +459,14 @@
<script src="src/js/boards.js"></script> <script src="src/js/boards.js"></script>
<script src="src/js/settings.js"></script> <script src="src/js/settings.js"></script>
<script src="src/js/search.js"></script> <script src="src/js/search.js"></script>
<script src="src/js/sticky.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/data.js"></script> <script src="src/js/data.js"></script>
<!-- Onboarding -->
<script src="src/js/onboarding.js"></script>
<!-- Einstiegspunkt zuletzt --> <!-- Einstiegspunkt zuletzt -->
<script src="src/js/app.js"></script> <script src="src/js/app.js"></script>
</body> </body>
+101
View File
@@ -0,0 +1,101 @@
# ⬡ 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
+1151 -181
View File
File diff suppressed because it is too large Load Diff
+106 -4
View File
@@ -16,9 +16,105 @@ async function init() {
bindGlobalEvents(); bindGlobalEvents();
bindSettingsEvents(); bindSettingsEvents();
initSearch(); initSearch();
initStickyNote(); await migrateSticky();
await Notes.init();
await Calculator.init();
await Timer.init();
await ImageRef.init();
initDataButtons(); initDataButtons();
Store.checkQuota(); Store.checkQuota();
// Onboarding beim ersten Start
const onboardingDone = await Store.get('onboardingDone');
if (!onboardingDone) {
Onboarding.start();
} else {
// Backup-Reminder (nur wenn Onboarding schon durch ist)
await checkBackupReminder();
}
}
// ---- 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
async function checkBackupReminder() {
const lastReminder = await Store.get('lastBackupReminder');
const now = Date.now();
// Beim allerersten Mal: Timestamp setzen, aber noch nicht nerven
if (!lastReminder) {
await Store.set('lastBackupReminder', now);
return;
}
if (now - lastReminder < BACKUP_INTERVAL_MS) return;
// Nur erinnern wenn es Boards gibt die sich lohnen zu sichern
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' }
);
if (doBackup) {
// JSON-Export auslösen (gleiche Logik wie btnExportJSON)
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: '1.9.0', exported: new Date().toISOString(), boards, settings, 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');
a.href = url;
a.download = 'hellion-newtab-backup-' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(url);
}
// Timestamp immer aktualisieren (egal ob gesichert oder "Später")
await Store.set('lastBackupReminder', now);
} }
// ---- CLOCK & DATE ---- // ---- CLOCK & DATE ----
@@ -50,12 +146,18 @@ function bindGlobalEvents() {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
const imported = parseBookmarkHtml(await file.text()); const imported = parseBookmarkHtml(await file.text());
if (imported.length === 0) { alert('Keine Bookmarks gefunden.'); return; } if (imported.length === 0) {
await HellionDialog.alert('Keine Bookmarks in dieser Datei gefunden.', { type: 'warning', title: 'Import' });
return;
}
boards = [...boards, ...imported]; boards = [...boards, ...imported];
await saveBoards(); await saveBoards();
renderBoards(); renderBoards();
e.target.value = ''; e.target.value = '';
alert(`${imported.length} Board(s) mit ${imported.reduce((s,b) => s + b.bookmarks.length, 0)} Bookmarks importiert.`); await HellionDialog.alert(
`${imported.length} Board(s) mit ${imported.reduce((s,b) => s + b.bookmarks.length, 0)} Bookmarks importiert.`,
{ type: 'success', title: 'Import erfolgreich' }
);
}); });
// Add Board Modal // Add Board Modal
@@ -86,7 +188,7 @@ function bindGlobalEvents() {
const url = document.getElementById('newBmUrl').value.trim(); const url = document.getElementById('newBmUrl').value.trim();
const desc = document.getElementById('newBmDesc').value.trim(); const desc = document.getElementById('newBmDesc').value.trim();
if (!title || !url) return; if (!title || !url) return;
try { new URL(url); } catch { alert('Ungültige URL. Bitte mit https:// beginnen.'); return; } try { new URL(url); } catch { await HellionDialog.alert('Ungültige URL. Bitte mit https:// beginnen.', { type: 'warning', title: 'URL ungültig' }); return; }
const board = boards.find(b => b.id === pendingBookmarkBoardId); const board = boards.find(b => b.id === pendingBookmarkBoardId);
if (!board) return; if (!board) return;
board.bookmarks.push({ id: uid(), title, url, desc }); board.bookmarks.push({ id: uid(), title, url, desc });
+105 -33
View File
@@ -6,16 +6,67 @@
let pendingBookmarkBoardId = null; let pendingBookmarkBoardId = null;
let pendingRenameCallback = null; let pendingRenameCallback = null;
const SVG_NS = 'http://www.w3.org/2000/svg';
/**
* Erzeugt ein SVG-Element mit Attributen und Kinder-Elementen.
* @param {string} tag - SVG-Tag (z.B. 'svg', 'circle', 'line')
* @param {Object} attrs - Attribute als Key-Value
* @param {Array} children - Kind-Elemente
* @returns {SVGElement}
*/
function svgEl(tag, attrs, children) {
const el = document.createElementNS(SVG_NS, tag);
if (attrs) {
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
}
if (children) {
for (const child of children) el.appendChild(child);
}
return el;
}
/** Erzeugt das 6-Punkt Drag-Handle SVG */
function createDragHandleSvg() {
return svgEl('svg', { width: '10', height: '14', viewBox: '0 0 10 14', fill: 'currentColor' }, [
svgEl('circle', { cx: '2', cy: '2', r: '1.5' }),
svgEl('circle', { cx: '8', cy: '2', r: '1.5' }),
svgEl('circle', { cx: '2', cy: '7', r: '1.5' }),
svgEl('circle', { cx: '8', cy: '7', r: '1.5' }),
svgEl('circle', { cx: '2', cy: '12', r: '1.5' }),
svgEl('circle', { cx: '8', cy: '12', r: '1.5' }),
]);
}
/** Erzeugt das Plus-Icon SVG */
function createPlusSvg() {
return svgEl('svg', { width: '11', height: '11', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
svgEl('line', { x1: '12', y1: '5', x2: '12', y2: '19' }),
svgEl('line', { x1: '5', y1: '12', x2: '19', y2: '12' }),
]);
}
// ---- RENDER ---- // ---- RENDER ----
function renderBoards() { function renderBoards() {
const wrapper = document.getElementById('boardsWrapper'); const wrapper = document.getElementById('boardsWrapper');
wrapper.innerHTML = ''; wrapper.replaceChildren();
if (boards.length === 0) { if (boards.length === 0) {
wrapper.innerHTML = `<div class="empty-state"> const empty = document.createElement('div');
No boards yet. Click <strong style="color:var(--accent)">+ Board</strong> to create one, empty.className = 'empty-state';
or use <strong style="color:var(--accent)">Import</strong> to load your browser bookmarks.
</div>`; const boardStrong = document.createElement('strong');
boardStrong.className = 'accent-text';
boardStrong.textContent = '+ Board';
const importStrong = document.createElement('strong');
importStrong.className = 'accent-text';
importStrong.textContent = 'Import';
empty.append(
'No boards yet. Click ', boardStrong, ' to create one, or use ', importStrong, ' to load your browser bookmarks.'
);
wrapper.appendChild(empty);
return; return;
} }
@@ -31,43 +82,59 @@ function createBoardEl(board) {
// Header // Header
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'board-header'; header.className = 'board-header';
header.innerHTML = `
<span class="board-drag-handle" title="Board verschieben"> const dragHandle = document.createElement('span');
<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor"> dragHandle.className = 'board-drag-handle';
<circle cx="2" cy="2" r="1.5"/><circle cx="8" cy="2" r="1.5"/> dragHandle.title = 'Board verschieben';
<circle cx="2" cy="7" r="1.5"/><circle cx="8" cy="7" r="1.5"/> dragHandle.appendChild(createDragHandleSvg());
<circle cx="2" cy="12" r="1.5"/><circle cx="8" cy="12" r="1.5"/>
</svg> const titleSpanHeader = document.createElement('span');
</span> titleSpanHeader.className = 'board-title';
<span class="board-title" title="${escHtml(board.title)}">${escHtml(board.title)}</span> titleSpanHeader.title = board.title;
<div class="board-actions"> titleSpanHeader.textContent = board.title;
<button class="board-action-btn btn-blur-board" title="${board.blurred ? 'Unblur' : 'Blur (privat)'}">🔒</button>
<button class="board-action-btn btn-rename-board" title="Umbenennen">✎</button> const actions = document.createElement('div');
<button class="board-action-btn btn-delete-board" title="Löschen">✕</button> actions.className = 'board-actions';
</div>
`; const btnBlur = document.createElement('button');
btnBlur.className = 'board-action-btn btn-blur-board';
btnBlur.title = board.blurred ? 'Unblur' : 'Blur (privat)';
btnBlur.textContent = '\uD83D\uDD12';
const btnRename = document.createElement('button');
btnRename.className = 'board-action-btn btn-rename-board';
btnRename.title = 'Umbenennen';
btnRename.textContent = '\u270E';
const btnDelete = document.createElement('button');
btnDelete.className = 'board-action-btn btn-delete-board';
btnDelete.title = 'Löschen';
btnDelete.textContent = '\u2715';
actions.append(btnBlur, btnRename, btnDelete);
header.append(dragHandle, titleSpanHeader, actions);
// Blur-Overlay // Blur-Overlay
const blurOverlay = document.createElement('div'); const blurOverlay = document.createElement('div');
blurOverlay.className = 'board-blur-overlay'; blurOverlay.className = 'board-blur-overlay';
div.appendChild(blurOverlay); div.appendChild(blurOverlay);
header.querySelector('.btn-blur-board').addEventListener('click', async e => { btnBlur.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation();
board.blurred = !board.blurred; board.blurred = !board.blurred;
div.classList.toggle('blurred', board.blurred); div.classList.toggle('blurred', board.blurred);
e.currentTarget.title = board.blurred ? 'Unblur' : 'Blur (privat)'; btnBlur.title = board.blurred ? 'Unblur' : 'Blur (privat)';
await saveBoards(); await saveBoards();
}); });
blurOverlay.addEventListener('click', async () => { blurOverlay.addEventListener('click', async () => {
board.blurred = false; board.blurred = false;
div.classList.remove('blurred'); div.classList.remove('blurred');
header.querySelector('.btn-blur-board').title = 'Blur (privat)'; btnBlur.title = 'Blur (privat)';
await saveBoards(); await saveBoards();
}); });
header.querySelector('.btn-rename-board').addEventListener('click', e => { btnRename.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
openRenameModal(board.title, async newName => { openRenameModal(board.title, async newName => {
if (!newName.trim()) return; if (!newName.trim()) return;
@@ -77,11 +144,16 @@ function createBoardEl(board) {
}); });
}); });
header.querySelector('.btn-delete-board').addEventListener('click', e => { btnDelete.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation();
if (confirm(`Board "${board.title}" löschen?`)) { const ok = await HellionDialog.confirm(
`Board "${board.title}" wirklich löschen?`,
{ type: 'danger', title: 'Board löschen', confirmText: 'Löschen' }
);
if (ok) {
boards = boards.filter(b => b.id !== board.id); boards = boards.filter(b => b.id !== board.id);
saveBoards().then(renderBoards); await saveBoards();
renderBoards();
} }
}); });
@@ -127,7 +199,8 @@ function createBoardEl(board) {
// Add Bookmark // Add Bookmark
const addBtn = document.createElement('button'); const addBtn = document.createElement('button');
addBtn.className = 'add-bm-btn'; addBtn.className = 'add-bm-btn';
addBtn.innerHTML = `<svg width="11" height="11" 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> Add link`; addBtn.appendChild(createPlusSvg());
addBtn.append(' Add link');
addBtn.addEventListener('click', () => openAddBookmarkModal(board.id)); addBtn.addEventListener('click', () => openAddBookmarkModal(board.id));
div.appendChild(addBtn); div.appendChild(addBtn);
@@ -148,13 +221,12 @@ function createBmEl(bm) {
favicon.height = 14; favicon.height = 14;
favicon.src = getFaviconUrl(bm.url); favicon.src = getFaviconUrl(bm.url);
favicon.addEventListener('error', function() { favicon.addEventListener('error', function() {
this.style.display = 'none'; this.classList.add('hidden');
this.nextElementSibling.style.display = 'flex'; this.nextElementSibling.classList.remove('hidden');
}); });
const fallback = document.createElement('div'); const fallback = document.createElement('div');
fallback.className = 'bm-favicon-fallback'; fallback.className = 'bm-favicon-fallback hidden';
fallback.style.display = 'none';
fallback.textContent = bm.title.charAt(0).toUpperCase(); fallback.textContent = bm.title.charAt(0).toUpperCase();
const textDiv = document.createElement('div'); const textDiv = document.createElement('div');
@@ -273,4 +345,4 @@ function parseBookmarkHtml(html) {
}); });
} }
return result; return result;
} }
+729
View File
@@ -0,0 +1,729 @@
/* =============================================
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,
// ---- 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 : [];
}
},
/**
* Calculator-State in Storage speichern
* Bestehende Notes-Daten bleiben erhalten
*/
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
const notesState = Array.isArray(data.notes) ? data.notes : [];
// Widget-Position aus WidgetManager holen
const widgetState = WidgetManager.getState(this.WIDGET_ID);
const calcData = {
x: widgetState ? widgetState.x : 400,
y: widgetState ? widgetState.y : 120,
width: widgetState ? widgetState.width : 280,
height: widgetState ? widgetState.height : 400,
open: this._isOpen,
history: this._history.slice(0, this.MAX_HISTORY)
};
await Store.set(this.STORAGE_KEY, { notes: notesState, calculator: calcData });
},
// ---- 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: 'Taschenrechner',
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() {
this._isOpen = false;
this._unbindKeyboard();
this._displayExprEl = null;
this._displayResultEl = null;
await this.save();
},
// ---- UI RENDERING ----
/**
* Calculator-Body rendern (in Widget-Body einfuegen)
* @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 = '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 = '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 '/': {
// 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 = 'Fehler';
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+\-*/.%()]/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];
// 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;
}
// Klammern
if (ch === '(' || ch === ')') {
tokens.push({ type: 'paren', value: ch });
i++;
continue;
}
// Unbekanntes Zeichen
return null;
}
return tokens;
},
/**
* Rekursiver Descent Parser mit Operator-Precedence
* @param {Array} tokens
* @returns {number|null}
*/
_parseExpression(tokens) {
let pos = 0;
function peek() { return tokens[pos]; }
function consume() { return tokens[pos++]; }
// Expression: Term (('+' | '-') Term)*
function parseExpr() {
let left = parseTerm();
if (left === null) return null;
while (pos < tokens.length) {
const t = peek();
if (!t || t.type !== 'op' || (t.value !== '+' && t.value !== '-')) break;
consume();
const right = parseTerm();
if (right === null) return null;
left = t.value === '+' ? left + right : left - right;
}
return left;
}
// Term: Factor (('*' | '/' | '%') Factor)*
function parseTerm() {
let left = parseFactor();
if (left === null) return null;
while (pos < tokens.length) {
const t = peek();
if (!t || t.type !== 'op' || (t.value !== '*' && t.value !== '/' && t.value !== '%')) break;
consume();
const right = parseFactor();
if (right === null) return null;
if (t.value === '*') {
left = left * right;
} else if (t.value === '/') {
if (right === 0) return null;
left = left / right;
} else {
left = left % right;
}
}
return left;
}
// Factor: Number | '(' Expression ')'
function parseFactor() {
const t = peek();
if (!t) return null;
if (t.type === 'number') {
consume();
return t.value;
}
if (t.type === 'paren' && t.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');
},
// ---- 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();
// 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();
}
// Close-Event abfangen: WidgetManager.close() ueberschreiben
const origClose = WidgetManager.close.bind(WidgetManager);
const self = this;
WidgetManager.close = function(id) {
origClose(id);
if (id === self.WIDGET_ID) {
self.onClose();
}
};
// Minimize-Event abfangen
const origMinimize = WidgetManager.minimize.bind(WidgetManager);
WidgetManager.minimize = async function(id) {
await origMinimize(id);
if (id === self.WIDGET_ID) {
self._isOpen = false;
await self.save();
}
};
// Open-Event abfangen
const origOpen = WidgetManager.openWidget.bind(WidgetManager);
WidgetManager.openWidget = async function(id) {
await origOpen(id);
if (id === self.WIDGET_ID) {
self._isOpen = true;
// Body neu rendern (war durch minimize entfernt)
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);
await self.save();
}
};
}
};
+78 -6
View File
@@ -9,9 +9,18 @@ function initDataButtons() {
const jsonInput = document.getElementById('jsonImportInput'); const jsonInput = document.getElementById('jsonImportInput');
if (!btnExport || !btnImport) return; if (!btnExport || !btnImport) return;
// Export // Export (inkl. Notes)
btnExport.addEventListener('click', () => { btnExport.addEventListener('click', async () => {
const data = { version: '1.2.0', exported: new Date().toISOString(), boards, settings }; const widgetData = await Store.get('widgetStates');
const data = {
version: '1.9.0',
exported: new Date().toISOString(),
boards,
settings,
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 blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -42,13 +51,76 @@ function initDataButtons() {
return true; return true;
}); });
if (validBoards.length === 0) throw new Error('Keine gültigen Boards gefunden'); if (validBoards.length === 0) throw new Error('Keine gültigen Boards gefunden');
if (!confirm(`${validBoards.length} Boards importieren? Bestehende Daten bleiben erhalten.`)) return; const ok = await HellionDialog.confirm(
`${validBoards.length} Boards importieren? Bestehende Daten bleiben erhalten.`,
{ type: 'info', title: 'JSON Import' }
);
if (!ok) return;
boards = [...boards, ...validBoards]; boards = [...boards, ...validBoards];
await saveBoards(); await saveBoards();
renderBoards(); renderBoards();
alert(`${validBoards.length} Board(s) importiert.`);
// 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 => {
if (!n || !n.id || !n.template) return false;
n.checklistItems = Array.isArray(n.checklistItems) ? n.checklistItems : [];
return true;
});
// 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;
Notes._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);
Calculator._history = existingWidgets.calculator.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);
Timer._presets = existingWidgets.timer.presets;
timerImported = true;
}
}
// Gemeinsam speichern
await Store.set('widgetStates', existingWidgets);
const noteMsg = notesImported > 0 ? ` + ${notesImported} Note(s)` : '';
const calcMsg = calcImported ? ' + Calculator-History' : '';
const timerMsg = timerImported ? ' + Timer-Presets' : '';
await HellionDialog.alert(
`${validBoards.length} Board(s)${noteMsg}${calcMsg}${timerMsg} erfolgreich importiert.`,
{ type: 'success', title: 'Import erfolgreich' }
);
} catch (err) { } catch (err) {
alert('Fehler beim Import: ' + err.message); await HellionDialog.alert('Fehler beim Import: ' + err.message, { type: 'danger', title: 'Import fehlgeschlagen' });
} }
e.target.value = ''; e.target.value = '';
}); });
+154
View File
@@ -0,0 +1,154 @@
/* =============================================
HELLION NEWTAB — dialog.js
Custom Dialog System (ersetzt native alert/confirm)
============================================= */
const HellionDialog = {
/** SVG-Icons je nach Dialog-Typ */
_icons: {
info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
success: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
warning: '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
danger: '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>'
},
/**
* Erzeugt das SVG-Icon-Element
* @param {string} type - info | success | warning | danger
* @returns {SVGElement}
*/
_createIcon(type) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '20');
svg.setAttribute('height', '20');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.className.baseVal = 'dialog-icon type-' + type;
// SVG-Pfade müssen per innerHTML gesetzt werden (kein User-Input, nur statische Pfade)
svg.innerHTML = this._icons[type] || this._icons.info;
return svg;
},
/**
* Erstellt und zeigt einen Dialog
* @param {Object} config
* @returns {Promise<boolean>}
*/
_show(config) {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'dialog-overlay';
const box = document.createElement('div');
box.className = 'dialog-box';
// Header
const header = document.createElement('div');
header.className = 'dialog-header';
header.appendChild(this._createIcon(config.type));
const titleSpan = document.createElement('span');
titleSpan.textContent = config.title;
header.appendChild(titleSpan);
// Body
const body = document.createElement('div');
body.className = 'dialog-body';
body.textContent = config.message;
// Actions
const actions = document.createElement('div');
actions.className = 'dialog-actions';
function cleanup(result) {
overlay.classList.remove('active');
document.removeEventListener('keydown', keyHandler);
setTimeout(() => overlay.remove(), 200);
resolve(result);
}
// Cancel-Button (nur bei confirm)
if (config.isConfirm) {
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-secondary';
cancelBtn.textContent = config.cancelText;
cancelBtn.addEventListener('click', () => cleanup(false));
actions.appendChild(cancelBtn);
}
// Confirm/OK-Button
const confirmBtn = document.createElement('button');
confirmBtn.className = config.type === 'danger' && config.isConfirm ? 'btn-danger' : 'btn-primary';
confirmBtn.textContent = config.confirmText;
confirmBtn.addEventListener('click', () => cleanup(config.isConfirm ? true : undefined));
actions.appendChild(confirmBtn);
box.append(header, body, actions);
overlay.appendChild(box);
// Overlay-Klick schließt
overlay.addEventListener('click', e => {
if (e.target === overlay) cleanup(config.isConfirm ? false : undefined);
});
// Keyboard
function keyHandler(e) {
if (e.key === 'Enter') {
e.preventDefault();
cleanup(config.isConfirm ? true : undefined);
}
if (e.key === 'Escape') {
e.preventDefault();
cleanup(config.isConfirm ? false : undefined);
}
}
document.addEventListener('keydown', keyHandler);
document.body.appendChild(overlay);
// Nächster Frame für CSS-Transition
requestAnimationFrame(() => {
overlay.classList.add('active');
confirmBtn.focus();
});
});
},
/**
* Zeigt einen Alert-Dialog (ersetzt window.alert)
* @param {string} message - Nachricht
* @param {Object} [options] - { title, confirmText, type }
* @returns {Promise<void>}
*/
alert(message, options) {
const opts = options || {};
return this._show({
message,
title: opts.title || 'Hinweis',
confirmText: opts.confirmText || 'OK',
cancelText: '',
type: opts.type || 'info',
isConfirm: false
});
},
/**
* Zeigt einen Confirm-Dialog (ersetzt window.confirm)
* @param {string} message - Nachricht
* @param {Object} [options] - { title, confirmText, cancelText, type }
* @returns {Promise<boolean>}
*/
confirm(message, options) {
const opts = options || {};
return this._show({
message,
title: opts.title || 'Bestätigung',
confirmText: opts.confirmText || 'OK',
cancelText: opts.cancelText || 'Abbrechen',
type: opts.type || 'info',
isConfirm: true
});
}
};
+42 -31
View File
@@ -37,13 +37,11 @@ function initBoardDragDrop() {
// Ghost // Ghost
const ghost = boardEl.cloneNode(true); const ghost = boardEl.cloneNode(true);
ghost.style.cssText = ` ghost.className += ' drag-ghost';
position:fixed; left:${rect.left}px; top:${rect.top}px; ghost.style.left = rect.left + 'px';
width:${rect.width}px; height:${rect.height}px; ghost.style.top = rect.top + 'px';
opacity:0.75; pointer-events:none; z-index:9999; ghost.style.width = rect.width + 'px';
transform:rotate(1.5deg) scale(1.02); ghost.style.height = rect.height + 'px';
box-shadow:0 12px 40px rgba(0,0,0,0.6);
`;
document.body.appendChild(ghost); document.body.appendChild(ghost);
// Placeholder // Placeholder
@@ -104,29 +102,42 @@ function initBoardDragDrop() {
function initBookmarkDragDrop(listEl, board) { function initBookmarkDragDrop(listEl, board) {
let dragSrcBmId = null; let dragSrcBmId = null;
listEl.querySelectorAll('.bm-item').forEach(item => { listEl.addEventListener('dragstart', e => {
item.addEventListener('dragstart', e => { const item = e.target.closest('.bm-item');
dragSrcBmId = item.dataset.bmId; if (!item) return;
e.dataTransfer.effectAllowed = 'move'; dragSrcBmId = item.dataset.bmId;
setTimeout(() => item.style.opacity = '0.4', 0); e.dataTransfer.effectAllowed = 'move';
}); setTimeout(() => item.classList.add('dragging-source'), 0);
item.addEventListener('dragend', () => { item.style.opacity = ''; }); });
item.addEventListener('dragover', e => {
e.preventDefault(); listEl.addEventListener('dragend', e => {
item.style.background = 'rgba(255,160,50,0.07)'; const item = e.target.closest('.bm-item');
}); if (item) item.classList.remove('dragging-source');
item.addEventListener('dragleave', () => { item.style.background = ''; }); });
item.addEventListener('drop', async e => {
e.preventDefault(); e.stopPropagation(); listEl.addEventListener('dragover', e => {
item.style.background = ''; e.preventDefault();
const targetBmId = item.dataset.bmId; const item = e.target.closest('.bm-item');
if (!dragSrcBmId || dragSrcBmId === targetBmId) return; if (item) item.classList.add('drag-over');
const srcIdx = board.bookmarks.findIndex(b => b.id === dragSrcBmId); });
const tgtIdx = board.bookmarks.findIndex(b => b.id === targetBmId);
const [moved] = board.bookmarks.splice(srcIdx, 1); listEl.addEventListener('dragleave', e => {
board.bookmarks.splice(tgtIdx, 0, moved); const item = e.target.closest('.bm-item');
await saveBoards(); if (item) item.classList.remove('drag-over');
renderBoards(); });
});
listEl.addEventListener('drop', async e => {
e.preventDefault(); e.stopPropagation();
const item = e.target.closest('.bm-item');
if (!item) return;
item.classList.remove('drag-over');
const targetBmId = item.dataset.bmId;
if (!dragSrcBmId || dragSrcBmId === targetBmId) return;
const srcIdx = board.bookmarks.findIndex(b => b.id === dragSrcBmId);
const tgtIdx = board.bookmarks.findIndex(b => b.id === targetBmId);
const [moved] = board.bookmarks.splice(srcIdx, 1);
board.bookmarks.splice(tgtIdx, 0, moved);
await saveBoards();
renderBoards();
}); });
} }
+500
View File
@@ -0,0 +1,500 @@
/* =============================================
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(
'Bild konnte nicht gespeichert werden. Der Browser-Speicher ist voll.',
{ type: 'danger', title: 'Speicherfehler' }
);
}
},
/**
* 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(
'Maximal ' + this.MAX_IMAGES + ' Bild-Widgets gleichzeitig. Schliesse eines um ein neues zu oeffnen.',
{ type: 'warning', title: 'Limit erreicht' }
);
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(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
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 || 'Bild-Referenz',
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 || 'Bild-Referenz';
wrapper.appendChild(img);
// Bild ersetzen Button
const replaceBtn = document.createElement('button');
replaceBtn.className = 'imgref-replace-btn';
replaceBtn.type = 'button';
replaceBtn.textContent = 'Bild ersetzen';
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(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
}
});
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 = 'Beschriftung (optional)';
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-text');
if (titleEl) titleEl.textContent = text || 'Bild-Referenz';
entry.state.title = text || 'Bild-Referenz';
}
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 = 'Klicken oder Bild hierher ziehen';
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(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
}
});
// 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(
'Bitte eine Bilddatei verwenden (PNG, JPG, WebP, etc.).',
{ type: 'warning', title: 'Kein Bild' }
);
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(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
}
});
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('Bild konnte nicht geladen werden'));
};
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);
}
});
}
// Close-Event abfangen
const self = this;
const prevClose = WidgetManager.close;
WidgetManager.close = function(id) {
prevClose.call(WidgetManager, id);
// Pruefen ob es ein Image-Widget ist
const isImage = self._images.some(img => img.id === id);
if (isImage) {
self.onClose(id);
}
};
// Minimize-Event abfangen
const prevMinimize = WidgetManager.minimize;
WidgetManager.minimize = async function(id) {
await prevMinimize.call(WidgetManager, id);
const isImage = self._images.some(img => img.id === id);
if (isImage) {
await self.save();
}
};
// Open-Event abfangen
const prevOpen = WidgetManager.openWidget;
WidgetManager.openWidget = async function(id) {
await prevOpen.call(WidgetManager, id);
const imgData = self._images.find(img => img.id === id);
if (imgData) {
const body = WidgetManager.getBody(id);
if (body && body.children.length === 0) {
const dataUrl = self._getSessionImage(id);
self.renderBody(imgData, body, dataUrl);
}
await 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(
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_NOTES + ' Notes gleichzeitig haben. Lösche eine bestehende Note um eine neue zu erstellen.',
{ type: 'warning', title: 'Limit erreicht' }
);
return null;
}
const noteData = {
id: 'note_' + uid(),
title: template === 'checklist' ? 'Checkliste' : 'Note',
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 = 'Notiz schreiben...';
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 = 'Neues Item...';
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 = done + '/' + total + ' erledigt';
const titleEl = widgetEntry.el.querySelector('.widget-title');
if (titleEl && titleEl.contentEditable !== 'true') {
// Nur wenn Titel noch Standard ist
if (noteData.title === 'Checkliste' || /^\d+\/\d+ erledigt$/.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(
'Note endgültig löschen? Das kann nicht rückgängig gemacht werden.',
{ type: 'danger', title: 'Note löschen', confirmText: 'Löschen' }
);
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*Exportiert aus Hellion Dashboard*\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 = done + '/' + total + ' erledigt';
} else {
preview.textContent = (note.content || '').slice(0, 50) || 'Leer';
}
// Actions
const actions = document.createElement('div');
actions.className = 'notebook-slot-actions';
const btnExport = document.createElement('button');
btnExport.className = 'notebook-slot-btn';
btnExport.textContent = '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 = '+ Note erstellen';
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 = '\u270E Freitext';
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 = '\u2611 Checkliste';
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());
}
}
};
+264
View File
@@ -0,0 +1,264 @@
/* =============================================
HELLION NEWTAB — onboarding.js
Mehrstufiger Willkommens-Flow beim ersten Start
============================================= */
const Onboarding = {
currentSlide: 0,
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.'
},
{
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)'
]
},
{
hero: '\uD83C\uDFA8',
title: '8 handgefertigte Themes',
text: 'Klicke auf den \u201ETheme\u201C Button im Header um dein Theme zu w\u00E4hlen. Jedes hat seinen eigenen Stil und Farbpalette.',
showThemes: true
},
{
hero: '\uD83E\uDDF0',
title: 'Widget-Toolbar',
features: [
'Die schwebenden Buttons rechts \u00F6ffnen Widgets',
'Notes und Checklisten f\u00FCr schnelle Notizen',
'Taschenrechner mit History',
'Timer/Countdown mit speicherbaren Presets',
'Bild-Referenz Widgets (aktivierbar in Settings)',
'Notebook-Sidebar zeigt alle Notes auf einen Blick'
]
},
{
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.'
},
{
hero: '\uD83C\uDFAE',
title: 'Gaming Starter Board',
text: 'Spielst du Games wie Satisfactory, Factorio oder Star Citizen? Ich kann ein Board mit n\u00FCtzlichen Community-Links anlegen.',
interactive: 'gaming-board'
},
{
hero: '\uD83D\uDE80',
title: 'Bereit!',
text: 'Erstelle dein erstes Board mit \u201E+ Board\u201C oder importiere deine Browser-Lesezeichen \u00FCber den Import-Button im Header. Viel Spa\u00DF!'
}
],
/** Startet das Onboarding */
start() {
this.currentSlide = 0;
this._render();
this._bindKeyboard();
const overlay = document.getElementById('onboardingOverlay');
requestAnimationFrame(() => overlay.classList.add('active'));
},
/** Schlie\u00DFt das Onboarding und speichert den Status */
async _finish() {
const overlay = document.getElementById('onboardingOverlay');
overlay.classList.remove('active');
document.removeEventListener('keydown', this._keyHandler);
await Store.set('onboardingDone', true);
},
/** Rendert den aktuellen Slide */
_render() {
const modal = document.getElementById('onboardingModal');
modal.replaceChildren();
const slide = this.slides[this.currentSlide];
const isLast = this.currentSlide === this.slides.length - 1;
// Skip-Button (nicht auf letztem Slide)
if (!isLast) {
const skip = document.createElement('button');
skip.className = 'onboarding-skip';
skip.textContent = '\u00DCberspringen';
skip.addEventListener('click', () => this._finish());
modal.appendChild(skip);
}
// Slide-Content
const slideEl = document.createElement('div');
slideEl.className = 'onboarding-slide';
const hero = document.createElement('div');
hero.className = 'onboarding-hero';
hero.textContent = slide.hero;
slideEl.appendChild(hero);
const title = document.createElement('div');
title.className = 'onboarding-title';
title.textContent = slide.title;
slideEl.appendChild(title);
if (slide.text) {
const text = document.createElement('div');
text.className = 'onboarding-text';
text.textContent = slide.text;
slideEl.appendChild(text);
}
if (slide.features) {
const list = document.createElement('ul');
list.className = 'onboarding-feature-list';
slide.features.forEach(f => {
const li = document.createElement('li');
li.textContent = f;
list.appendChild(li);
});
slideEl.appendChild(list);
}
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'];
themeNames.forEach(name => {
const chip = document.createElement('div');
chip.className = 'onboarding-theme-chip';
chip.textContent = name;
grid.appendChild(chip);
});
slideEl.appendChild(grid);
}
modal.appendChild(slideEl);
// Footer
const footer = document.createElement('div');
footer.className = 'onboarding-footer';
// Dots
const dots = document.createElement('div');
dots.className = 'onboarding-dots';
for (let i = 0; i < this.slides.length; i++) {
const dot = document.createElement('div');
dot.className = 'onboarding-dot' + (i === this.currentSlide ? ' active' : '');
dots.appendChild(dot);
}
footer.appendChild(dots);
// Navigation
const nav = document.createElement('div');
nav.className = 'onboarding-nav';
if (this.currentSlide > 0) {
const backBtn = document.createElement('button');
backBtn.className = 'btn-secondary';
backBtn.textContent = 'Zur\u00FCck';
backBtn.addEventListener('click', () => {
this.currentSlide--;
this._render();
});
nav.appendChild(backBtn);
}
if (slide.interactive === 'gaming-board') {
// Interaktive Slide: Zwei Buttons statt "Weiter"
const noBtn = document.createElement('button');
noBtn.className = 'btn-secondary';
noBtn.textContent = 'Nein danke';
noBtn.addEventListener('click', () => {
this.currentSlide++;
this._render();
});
const yesBtn = document.createElement('button');
yesBtn.className = 'btn-primary';
yesBtn.textContent = 'Ja, gerne';
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.addEventListener('click', () => this._finish());
nav.appendChild(startBtn);
} else {
const nextBtn = document.createElement('button');
nextBtn.className = 'btn-primary';
nextBtn.textContent = 'Weiter';
nextBtn.addEventListener('click', () => {
this.currentSlide++;
this._render();
});
nav.appendChild(nextBtn);
}
footer.appendChild(nav);
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: 'Trade Center f\u00FCr Star Citizen' }
],
blurred: false
};
boards.push(gamingBoard);
await saveBoards();
renderBoards();
},
/** Keyboard-Navigation */
_bindKeyboard() {
this._keyHandler = (e) => {
if (e.key === 'ArrowRight' || e.key === 'Enter') {
e.preventDefault();
if (this.currentSlide < this.slides.length - 1) {
this.currentSlide++;
this._render();
} else {
this._finish();
}
}
if (e.key === 'ArrowLeft' && this.currentSlide > 0) {
e.preventDefault();
this.currentSlide--;
this._render();
}
if (e.key === 'Escape') {
e.preventDefault();
this._finish();
}
};
document.addEventListener('keydown', this._keyHandler);
}
};
+47
View File
@@ -0,0 +1,47 @@
# ⬡ 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.
---
## Warum zwei extra Skripte?
| 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 |
---
## Was passiert hier?
### `background.js` — Tab-Management
Überwacht Tab-Aktivitäten im Hintergrund und greift ein bevor Opera seine Startseite lädt.
- 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
### `redirect.js` — In-Page Redirect
Einige Opera-Systemprozesse sind so isoliert dass ein externer Eingriff nicht zuverlässig greift.
- Wird als Content Script in Opera-Startseiten-Bereiche injiziert
- Löst den Redirect bei `document_start` aus — minimale Verzögerung, kein Flackern
---
## Datenschutz
Kein Tracking, keine Speicherung, keine externen Requests.
Ausschließlich Standard-Browser-APIs — `chrome.tabs` — um die Kontrolle über den New Tab zurückzugewinnen.
**100% lokal. 0% Analytics. Wie im gesamten Hellion NewTab Projekt.**
---
Entwickelt von **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion
+34
View File
@@ -0,0 +1,34 @@
const dashboardUrl = chrome.runtime.getURL("newtab.html");
// Diese URLs wollen wir abfangen
const targetUrls = [
"chrome://startpage/",
"opera://startpage/",
"chrome://startpageshared/",
"about:blank"
];
function forceRedirect(tabId, url) {
if (url && targetUrls.some(target => url.startsWith(target))) {
chrome.tabs.update(tabId, { url: dashboardUrl });
}
}
// 1. Check beim Erstellen
chrome.tabs.onCreated.addListener((tab) => {
forceRedirect(tab.id, tab.pendingUrl || tab.url);
});
// 2. Check beim Aktualisieren (Wichtig für Opera GX!)
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === "loading" || changeInfo.url) {
forceRedirect(tabId, tab.url);
}
});
// 3. Intervall-Check: Falls Opera den Event verschluckt
chrome.tabs.onActivated.addListener((activeInfo) => {
chrome.tabs.get(activeInfo.tabId, (tab) => {
if (tab) forceRedirect(tab.id, tab.url);
});
});
+6
View File
@@ -0,0 +1,6 @@
(function() {
const dashboardUrl = chrome.runtime.getURL("newtab.html");
if (window.location.href !== dashboardUrl) {
window.location.href = dashboardUrl;
}
})();
+100 -13
View File
@@ -1,8 +1,9 @@
/* ============================================= /* =============================================
HELLION NEWTAB — settings.js HELLION NEWTAB — settings.js
Settings Panel: Toggles, Hintergrund, Theme-Picker Settings Panel, Theme-Modal, Accordion, Toggles
============================================= */ ============================================= */
// ---- SETTINGS PANEL ----
function openSettings() { function openSettings() {
document.getElementById('settingsPanel').classList.add('open'); document.getElementById('settingsPanel').classList.add('open');
document.getElementById('settingsOverlay').classList.add('active'); document.getElementById('settingsOverlay').classList.add('active');
@@ -12,6 +13,43 @@ function closeSettings() {
document.getElementById('settingsOverlay').classList.remove('active'); document.getElementById('settingsOverlay').classList.remove('active');
} }
// ---- THEME MODAL ----
function openThemeModal() {
const overlay = document.getElementById('themeOverlay');
overlay.classList.add('active');
}
function closeThemeModal() {
const overlay = document.getElementById('themeOverlay');
overlay.classList.remove('active');
}
// ---- ACCORDION ----
function initAccordion() {
const defaultOpen = new Set(['widgets']);
const sections = document.querySelectorAll('.settings-section[data-section]');
sections.forEach(section => {
const name = section.dataset.section;
const title = section.querySelector('.settings-section-title');
if (defaultOpen.has(name)) {
section.classList.add('open');
}
title.addEventListener('click', () => {
section.classList.toggle('open');
});
title.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
section.classList.toggle('open');
}
});
});
}
// ---- APPLY SETTINGS ----
function applySettings() { function applySettings() {
const body = document.body; const body = document.body;
body.classList.toggle('compact', settings.compact); body.classList.toggle('compact', settings.compact);
@@ -24,7 +62,7 @@ function applySettings() {
document.getElementById('settingShowDesc').checked = settings.showDesc; document.getElementById('settingShowDesc').checked = settings.showDesc;
document.getElementById('settingHideExtra').checked = settings.hideExtra; document.getElementById('settingHideExtra').checked = settings.hideExtra;
document.getElementById('settingVisibleCount').value = String(settings.visibleCount); document.getElementById('settingVisibleCount').value = String(settings.visibleCount);
document.getElementById('visibleCountRow').style.opacity = settings.hideExtra ? '1' : '0.4'; document.getElementById('visibleCountRow').classList.toggle('dim', !settings.hideExtra);
// showSearch: undefined (alter Save) → true // showSearch: undefined (alter Save) → true
if (settings.showSearch === undefined) settings.showSearch = true; if (settings.showSearch === undefined) settings.showSearch = true;
@@ -33,6 +71,18 @@ function applySettings() {
const showSearchEl = document.getElementById('settingShowSearch'); const showSearchEl = document.getElementById('settingShowSearch');
if (showSearchEl) showSearchEl.checked = settings.showSearch; 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);
// Toolbar-Position
document.body.classList.toggle('toolbar-left', settings.toolbarPos === 'left');
const toolbarPosEl = document.getElementById('settingToolbarPos');
if (toolbarPosEl) toolbarPosEl.value = settings.toolbarPos || 'right';
applyTheme(settings.theme || 'nebula', !!settings.bgUrl); applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
if (settings.bgUrl) { if (settings.bgUrl) {
@@ -40,13 +90,21 @@ function applySettings() {
} }
} }
// ---- BIND EVENTS ----
function bindSettingsEvents() { function bindSettingsEvents() {
// Panel // Settings Panel
document.getElementById('settingsOverlay').addEventListener('click', closeSettings); document.getElementById('settingsOverlay').addEventListener('click', closeSettings);
document.getElementById('btnCloseSettings').addEventListener('click', closeSettings); document.getElementById('btnCloseSettings').addEventListener('click', closeSettings);
document.getElementById('btnSettings').addEventListener('click', openSettings); document.getElementById('btnSettings').addEventListener('click', openSettings);
// Theme-Picker // Theme Modal
document.getElementById('btnTheme').addEventListener('click', openThemeModal);
document.getElementById('btnCloseTheme').addEventListener('click', closeThemeModal);
document.getElementById('themeOverlay').addEventListener('click', e => {
if (e.target === document.getElementById('themeOverlay')) closeThemeModal();
});
// Theme-Picker (Cards im Theme-Modal)
document.querySelectorAll('.theme-card').forEach(card => { document.querySelectorAll('.theme-card').forEach(card => {
card.addEventListener('click', async () => { card.addEventListener('click', async () => {
const name = card.dataset.value; const name = card.dataset.value;
@@ -59,6 +117,9 @@ function bindSettingsEvents() {
}); });
}); });
// Accordion initialisieren
initAccordion();
// Toggles // Toggles
const toggleMap = { const toggleMap = {
settingCompact: v => { settings.compact = v; document.body.classList.toggle('compact', v); }, settingCompact: v => { settings.compact = v; document.body.classList.toggle('compact', v); },
@@ -67,12 +128,17 @@ function bindSettingsEvents() {
settingShowDesc: v => { settings.showDesc = v; document.body.classList.toggle('show-desc', v); }, settingShowDesc: v => { settings.showDesc = v; document.body.classList.toggle('show-desc', v); },
settingHideExtra: v => { settingHideExtra: v => {
settings.hideExtra = v; settings.hideExtra = v;
document.getElementById('visibleCountRow').style.opacity = v ? '1' : '0.4'; document.getElementById('visibleCountRow').classList.toggle('dim', !v);
renderBoards(); renderBoards();
}, },
settingShowSearch: v => { settingShowSearch: v => {
settings.showSearch = v; settings.showSearch = v;
document.getElementById('searchBarWrapper').classList.toggle('hidden', !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);
} }
}; };
@@ -92,20 +158,19 @@ function bindSettingsEvents() {
renderBoards(); renderBoards();
}); });
// Background URL // Background URL (im Theme-Modal)
document.getElementById('btnChangeBg').addEventListener('click', () => { document.getElementById('btnChangeBg').addEventListener('click', () => {
const row = document.getElementById('bgInputRow'); document.getElementById('bgInputRow').classList.toggle('hidden');
row.style.display = row.style.display === 'none' ? 'flex' : 'none';
}); });
document.getElementById('btnApplyBg').addEventListener('click', async () => { document.getElementById('btnApplyBg').addEventListener('click', async () => {
const url = document.getElementById('bgUrlInput').value.trim(); const url = document.getElementById('bgUrlInput').value.trim();
settings.bgUrl = url; settings.bgUrl = url;
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : ''; document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
await saveSettings(); await saveSettings();
document.getElementById('bgInputRow').style.display = 'none'; document.getElementById('bgInputRow').classList.add('hidden');
}); });
// Background File Upload // Background File Upload (im Theme-Modal)
document.getElementById('btnBgFile').addEventListener('click', () => { document.getElementById('btnBgFile').addEventListener('click', () => {
document.getElementById('bgFileInput').click(); document.getElementById('bgFileInput').click();
}); });
@@ -119,18 +184,40 @@ function bindSettingsEvents() {
await saveSettings(); await saveSettings();
}; };
reader.onerror = () => { reader.onerror = () => {
alert('Fehler beim Lesen der Datei. Bitte eine andere Datei wählen.'); HellionDialog.alert('Fehler beim Lesen der Datei. Bitte eine andere Datei wählen.', { type: 'danger', title: 'Dateifehler' });
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
// 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();
});
// Reset All // Reset All
document.getElementById('btnResetAll').addEventListener('click', async () => { document.getElementById('btnResetAll').addEventListener('click', async () => {
if (!confirm('Wirklich alle Boards und Einstellungen löschen? Nicht rückgängig machbar.')) return; 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' }
);
if (!ok) return;
boards = []; boards = [];
settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false, settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false,
hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula', hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula',
showSearch: true, searchEngine: 'google' }; showSearch: true, searchEngine: 'google', toolbarPos: 'right',
imageRefEnabled: false };
await saveBoards(); await saveBoards();
await saveSettings(); await saveSettings();
applySettings(); applySettings();
+3 -1
View File
@@ -15,7 +15,9 @@ let settings = {
bgUrl: '', bgUrl: '',
theme: 'nebula', theme: 'nebula',
showSearch: true, showSearch: true,
searchEngine: 'google' searchEngine: 'google',
toolbarPos: 'right',
imageRefEnabled: false
}; };
function uid() { function uid() {
+2 -2
View File
@@ -23,7 +23,7 @@ const Store = {
chrome.storage.local.set({ [key]: value }, () => { chrome.storage.local.set({ [key]: value }, () => {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
console.error('Storage-Fehler:', chrome.runtime.lastError.message); console.error('Storage-Fehler:', chrome.runtime.lastError.message);
alert('Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.'); HellionDialog.alert('Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.', { type: 'danger', title: 'Speicher voll' });
reject(new Error(chrome.runtime.lastError.message)); reject(new Error(chrome.runtime.lastError.message));
return; return;
} }
@@ -35,7 +35,7 @@ const Store = {
resolve(); resolve();
} catch (e) { } catch (e) {
console.error('Storage-Fehler:', e.message); console.error('Storage-Fehler:', e.message);
alert('Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.'); HellionDialog.alert('Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.', { type: 'danger', title: 'Speicher voll' });
reject(e); reject(e);
} }
} }
+760
View File
@@ -0,0 +1,760 @@
/* =============================================
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: 'Timer',
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 = '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 = '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 = '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 = 'Presets';
const addBtn = document.createElement('button');
addBtn.className = 'timer-preset-add';
addBtn.type = 'button';
addBtn.textContent = '+';
addBtn.title = 'Preset speichern';
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(
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_PRESETS + ' Presets speichern.',
{ type: 'warning', title: 'Limit erreicht' }
);
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(
'Gib zuerst eine Zeit ein, bevor du ein Preset speicherst.',
{ type: 'info', title: 'Keine Zeit' }
);
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 = 'Name...';
nameInput.maxLength = 20;
const confirmBtn = document.createElement('button');
confirmBtn.className = 'timer-add-confirm';
confirmBtn.type = 'button';
confirmBtn.textContent = '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 === '[!] Timer abgelaufen'
? this._originalTitle
: '[!] Timer abgelaufen';
}, 1000);
},
/**
* Tab-Titel Blink und Alarm stoppen
*/
_stopAlarm() {
if (this._blinkIntervalId) {
clearInterval(this._blinkIntervalId);
this._blinkIntervalId = null;
document.title = this._originalTitle || 'Hellion Dashboard';
}
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 ? 'Ton einschalten' : 'Ton ausschalten';
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 ? 'Neustart' : '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();
}
// Close-Event abfangen
const origClose = WidgetManager.close.bind(WidgetManager);
const self = this;
const prevClose = WidgetManager.close;
WidgetManager.close = function(id) {
prevClose.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self.onClose();
}
};
// Minimize-Event abfangen
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();
}
};
// Open-Event abfangen
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);
}
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
if (entry) self._bindKeyboard(entry.el);
await self.save();
}
};
}
};
+365
View File
@@ -0,0 +1,365 @@
/* =============================================
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',
/**
* 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 || 'Note',
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 });
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 || 'Note';
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 = 'Minimieren';
btnMin.textContent = '\u2500';
btnMin.addEventListener('click', () => this.minimize(state.id));
const btnClose = document.createElement('button');
btnClose.className = 'widget-btn widget-close';
btnClose.title = 'Schließen';
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._widgets.delete(id);
},
/**
* Widget minimieren (aus DOM verstecken, bleibt im Notebook)
* @param {string} id
*/
async minimize(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry.state.open = false;
entry.el.classList.add('widget-minimized');
setTimeout(() => {
entry.el.style.display = 'none';
}, 250);
await this.save();
},
/**
* Widget oeffnen (aus minimiertem Zustand wiederherstellen)
* @param {string} id
*/
async openWidget(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry.state.open = true;
entry.el.style.display = 'flex';
// Naechster Frame fuer Animation
requestAnimationFrame(() => {
entry.el.classList.remove('widget-minimized');
});
this.bringToFront(id);
await this.save();
},
/**
* 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());
}
};