Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 677344f24d | |||
| 40d4d9f37a | |||
| 198171b6c2 | |||
| 51947b229c | |||
| 2f0b76eb4e | |||
| 32a6fe88dc | |||
| 95e45948be | |||
| a76f63c407 | |||
| 18a04b884c | |||
| 7a16462358 | |||
| ed11827321 | |||
| 6172332be7 | |||
| 74e3eaefcf | |||
| 00baa0231b | |||
| 36bf38a92c | |||
| b430b67df9 |
@@ -0,0 +1,4 @@
|
|||||||
|
# Hellion NewTab — Code Owners
|
||||||
|
# Alle Änderungen müssen von @JonKazama-Hellion approved werden
|
||||||
|
|
||||||
|
* @JonKazama-Hellion
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Hellion NewTab — Support & Funding
|
||||||
|
# All tools are free and open-source. Donations are voluntary and go toward server costs.
|
||||||
|
|
||||||
|
ko_fi: hellionmedia
|
||||||
|
|
||||||
|
custom:
|
||||||
|
- "https://hellion-media.de"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Release — erstellt ZIP-Pakete für Chrome und Firefox bei neuem Tag
|
# Release — creates ZIP packages for Chrome, Firefox and Opera on new tag
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -17,35 +17,42 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Version aus Tag extrahieren
|
- name: Extract version from tag
|
||||||
id: version
|
id: version
|
||||||
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Chrome/Edge ZIP erstellen (Manifest V3)
|
- name: Create Chrome/Edge ZIP (Manifest V3)
|
||||||
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: Create Firefox ZIP (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
|
mv manifest.chrome-backup.json manifest.json
|
||||||
|
|
||||||
- name: SHA256 Checksummen erstellen
|
- name: Create Opera/Opera GX ZIP (Manifest V3 + workaround)
|
||||||
|
run: |
|
||||||
|
cp manifest.json manifest.chrome-backup.json
|
||||||
|
cp manifest.opera.json manifest.json
|
||||||
|
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \
|
||||||
|
manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ \
|
||||||
|
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.opera.json"
|
||||||
|
mv manifest.chrome-backup.json manifest.json
|
||||||
|
|
||||||
|
- name: Generate SHA256 checksums
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
cd dist
|
||||||
sha256sum *.zip > checksums-sha256.txt
|
sha256sum *.zip > checksums-sha256.txt
|
||||||
cat checksums-sha256.txt
|
cat checksums-sha256.txt
|
||||||
|
|
||||||
- name: GitHub Release erstellen
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
name: "Hellion NewTab ${{ steps.version.outputs.tag }}"
|
name: "Hellion NewTab ${{ steps.version.outputs.tag }}"
|
||||||
@@ -53,15 +60,17 @@ 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.
|
See [README](README.md) for the full installation instructions.
|
||||||
|
|
||||||
### Checksummen
|
### Checksums
|
||||||
Siehe `checksums-sha256.txt` zur Integritätsprüfung.
|
See `checksums-sha256.txt` to verify file integrity.
|
||||||
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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# ⬡ Hellion Dashboard — Changelog
|
||||||
|
|
||||||
|
All notable changes per version. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
> Changelog entries can be written in English or German. English preferred for consistency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v1.10.0 — 22.03.2026
|
||||||
|
|
||||||
|
#### Themes
|
||||||
|
|
||||||
|
- **3 new themes** — Satisfactory (Industrial Desert), Avorion (Deep Void) and Hellion Stealth (Tactical Recon)
|
||||||
|
- Now **11 themes** total, each with its own accent colors, overlays and font styles
|
||||||
|
- Satisfactory has increased board alpha (0.65) and stronger blur (12px), a deliberate choice for better readability on a visually busy background
|
||||||
|
- Avorion uses a radial gradient overlay so the ship in the center of the image stays visible
|
||||||
|
- Hellion Stealth is the only theme with a `border-left` hover effect in tactical scanner style
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v1.9.0 — 22.03.2026
|
||||||
|
|
||||||
|
#### New Features
|
||||||
|
|
||||||
|
- **Onboarding reworked** — 7 slides instead of 6, new slide explains the widget toolbar with all widgets
|
||||||
|
- **Gaming Starter Board** — Opt-in during onboarding: pre-filled board with links to Satisfactory, Factorio, Avorion, Minecraft and Star Citizen
|
||||||
|
- **Settings redesign** — Settings panel slimmed down to 3 sections (Widgets, Data & Help, Danger Zone)
|
||||||
|
- **Appearance modal** — Theme picker and all display settings combined in one modal instead of spread across the panel
|
||||||
|
- **Fixed about footer** — Developer info, license and links are now permanently visible at the bottom of the settings panel
|
||||||
|
- **Project documentation** — `docs/architecture.md`, `docs/widget-schema.md` and `docs/patterns.md` for anyone who wants to fork or contribute
|
||||||
|
|
||||||
|
#### Improvements
|
||||||
|
|
||||||
|
- All labels and descriptions unified in German, no more language mix
|
||||||
|
- Dropdown options use theme colors instead of white browser default
|
||||||
|
- Firefox update URL for store publishing added to `manifest.firefox.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v1.8.0 — 21.03.2026
|
||||||
|
|
||||||
|
#### New Features
|
||||||
|
|
||||||
|
- **Image Reference Widget** — Drop images as floating reference widgets (max. 3 at once)
|
||||||
|
- Canvas API WebP conversion for smaller file sizes, all local in the browser
|
||||||
|
- Two-layer storage: metadata persistent, image data session-only (sessionStorage)
|
||||||
|
- Load images via drag & drop or file dialog
|
||||||
|
- Labels editable with debounced save
|
||||||
|
- Feature is off by default, enable via Settings → Widgets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v1.7.1 — 21.03.2026
|
||||||
|
|
||||||
|
#### Improvements
|
||||||
|
|
||||||
|
- **Timer mute toggle** — Alarm can be muted via icon button without restarting the timer
|
||||||
|
- Alarm volume reduced to 7%, 30% was a bit much
|
||||||
|
- Mute state is saved and persists on next open
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v1.7.0 — 21.03.2026
|
||||||
|
|
||||||
|
#### New Features
|
||||||
|
|
||||||
|
- **Calculator widget** — Shunting-yard parser (no `eval()`), history of last calculations, keyboard input
|
||||||
|
- **Timer/countdown widget** — Saveable presets, Web Audio API alarm, tab title blinks when timer completes
|
||||||
|
- **Widget z-index fix** — Widgets now correctly render above the search bar (z-index 100+)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v1.6.0 — 21.03.2026
|
||||||
|
|
||||||
|
#### New Features
|
||||||
|
|
||||||
|
- **Widget system** — Draggable, resizable floating panels managed by WidgetManager
|
||||||
|
- **Notes & checklists** — Multi-instance widgets (max. 5) with text and checklist template, Markdown support, export as `.md`
|
||||||
|
- **Notebook sidebar** — All notes at a glance, quick access via toolbar
|
||||||
|
- **Widget toolbar** — Floating buttons on the side for quick access to all widgets, position (left/right) configurable in Settings
|
||||||
|
- **Sticky note migration** — Old sticky notes are automatically migrated to the new widget system on first launch
|
||||||
|
|
||||||
|
#### Improvements
|
||||||
|
|
||||||
|
- Ko-fi support link added to the about section and `FUNDING.yml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v1.5.2 — 21.03.2026
|
||||||
|
|
||||||
|
#### New Features
|
||||||
|
|
||||||
|
- **Custom dialog system** — Native `confirm()` and `alert()` replaced with frosted glass dialogs (`dialog.js`)
|
||||||
|
- **Onboarding** — 6-step welcome flow on first launch with explanations for boards, themes, features and a backup reminder
|
||||||
|
- **Backup reminder** — Reminds every 7 days to run a JSON export and warns about data loss on browser reset
|
||||||
|
- **Theme modal** — Theme picker moved to its own modal with its own header button
|
||||||
|
- **Accordion settings** — All settings sections collapsible (About and Danger Zone closed by default)
|
||||||
|
|
||||||
|
#### Improvements
|
||||||
|
|
||||||
|
- Fonts migrated from Google Fonts API to local WOFF2 files (GDPR, ~388 KB saved)
|
||||||
|
- `innerHTML` fully replaced with `createElement` and `createElementNS` (XSS protection)
|
||||||
|
- SVG icons now via `createElementNS` instead of inline HTML
|
||||||
|
- Drag & drop uses CSS classes instead of inline styles (`.drag-ghost`, `.drag-over`, `.dragging-source`)
|
||||||
|
- Search bar toggle moved from DATA to BEHAVIOR section
|
||||||
|
- Unimplemented "Quick Save" UI element removed
|
||||||
|
- Onboarding repeatable via Settings → Help
|
||||||
|
|
||||||
|
#### Opera / Opera GX
|
||||||
|
|
||||||
|
- `manifest.opera.json` added (MV3 with workaround scripts)
|
||||||
|
- `src/js/opera/background.js` monitors tabs and redirects away from Opera Speed Dial
|
||||||
|
- `src/js/opera/redirect.js` fires as content script at `document_start`
|
||||||
|
|
||||||
|
#### Firefox
|
||||||
|
|
||||||
|
- `manifest.firefox.json` migrated to Manifest V3
|
||||||
|
- `browser_specific_settings` with Gecko ID and `data_collection_permissions` added
|
||||||
|
|
||||||
|
#### Build & CI
|
||||||
|
|
||||||
|
- GitHub Actions release now builds 3 ZIP packages (Chrome, Firefox, Opera)
|
||||||
|
- Quality check validates all 3 manifests and the Opera folder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v1.2.0 — 20.03.2026
|
||||||
|
|
||||||
|
- Project structure split into `src/js/`, `src/css/` and `assets/`
|
||||||
|
- JS split into 10 modules (storage, state, themes, boards, drag, settings, search, sticky, data, app)
|
||||||
|
- Firefox compatibility (`manifest.firefox.json`, Manifest V3)
|
||||||
|
- Vivaldi confirmed compatible
|
||||||
|
- Theme image paths fixed (settings preview)
|
||||||
|
- URL validation on bookmark creation
|
||||||
|
- JSON import validates board and bookmark structure
|
||||||
|
- XSS protection: `createElement` instead of `innerHTML` for bookmarks
|
||||||
|
- Storage quota check with warning at 8 MB+
|
||||||
|
- Event delegation for bookmark clicks (performance)
|
||||||
|
- Responsive design (tablet 768px, smartphone 480px)
|
||||||
|
- Sticky note header collision fixed
|
||||||
|
- FileReader error handling for background image upload
|
||||||
|
- GitHub Actions: security scan, code quality, release automation
|
||||||
|
- 3 themes replaced: Astronaut → Nebula, Cosmic Clock → Crescent, Void Mage → Event Horizon
|
||||||
|
- All theme images checked and documented for license compliance
|
||||||
|
- LICENSE (CC BY-NC-SA 4.0), SECURITY.md and DISCLAIMER.md added
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v1.1.0 — 20.03.2026
|
||||||
|
|
||||||
|
- 5 new themes (Merchantman, Julia & Jin, SC Sunset, Hellion HUD, Hellion Energy)
|
||||||
|
- Search bar (Google, DuckDuckGo, Bing)
|
||||||
|
- Sticky note widget
|
||||||
|
- JSON export & import
|
||||||
|
- Date next to the clock
|
||||||
|
- About / imprint in settings
|
||||||
|
- Board blur function (privacy mode)
|
||||||
|
- Drag & drop migrated to Pointer Events API
|
||||||
|
- Opera / Opera GX compatibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v1.0.0 — 20.03.2026
|
||||||
|
|
||||||
|
- Initial release
|
||||||
|
- Boards & bookmarks with drag & drop
|
||||||
|
- 3 themes (Nebula, Crescent, Event Horizon)
|
||||||
|
- HTML import (browser bookmarks)
|
||||||
|
- Settings panel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
|
||||||
@@ -1,47 +1,81 @@
|
|||||||
# Haftungsausschluss — Hellion NewTab
|
# Disclaimer — Hellion NewTab
|
||||||
|
|
||||||
## Nutzung auf eigenes Risiko
|
## Use at Your Own Risk
|
||||||
|
|
||||||
Diese Browser-Extension wird "wie besehen" (as-is) zur Verfügung gestellt, ohne jegliche ausdrückliche oder stillschweigende Gewährleistung, einschließlich, aber nicht beschränkt auf die Gewährleistung der Marktgängigkeit, der Eignung für einen bestimmten Zweck und der Nichtverletzung von Rechten Dritter.
|
This browser extension is provided "as is", without warranty of any kind, express
|
||||||
|
or implied, including but not limited to the warranties of merchantability, fitness
|
||||||
|
for a particular purpose and non-infringement.
|
||||||
|
|
||||||
## Keine Garantie
|
## No Guarantee
|
||||||
|
|
||||||
Der Entwickler übernimmt keine Haftung für:
|
The developer assumes no liability for:
|
||||||
|
|
||||||
- Datenverlust durch fehlerhafte Speicherung, Browser-Updates oder Extension-Deinstallation
|
- Data loss caused by storage errors, browser updates or extension uninstallation
|
||||||
- Inkompatibilitäten mit bestimmten Browser-Versionen oder Betriebssystemen
|
- Incompatibilities with specific browser versions or operating systems
|
||||||
- Schäden, die durch die Nutzung oder Nichtnutzung dieser Extension entstehen
|
- Damages arising from the use or inability to use this extension
|
||||||
- Verfügbarkeit oder Korrektheit von Drittanbieter-Diensten (Google Favicons API)
|
- Availability or accuracy of third-party services (Google Favicons API)
|
||||||
|
|
||||||
## Datenspeicherung
|
## Data Storage
|
||||||
|
|
||||||
Alle Daten werden ausschließlich lokal im Browser gespeichert (`chrome.storage.local`). Es erfolgt keine Übertragung an externe Server. Der Entwickler hat keinen Zugriff auf gespeicherte Bookmarks, Einstellungen oder Notizen.
|
All data is stored exclusively in the local browser (`chrome.storage.local`).
|
||||||
|
No data is transmitted to external servers. The developer has no access to stored
|
||||||
|
bookmarks, settings, notes or any other user data.
|
||||||
|
|
||||||
**Empfehlung:** Regelmäßig JSON-Backups über die Export-Funktion erstellen.
|
**Recommendation:** Create regular JSON backups using the export function in Settings.
|
||||||
|
|
||||||
## Drittanbieter-Dienste
|
## No Guaranteed Updates
|
||||||
|
|
||||||
Diese Extension nutzt folgende externe Dienste:
|
This extension is maintained by a single developer in their spare time.
|
||||||
|
Continued development and updates are not guaranteed. Features may change,
|
||||||
|
projects may pause, and support is provided on a best-effort basis, not as an obligation.
|
||||||
|
|
||||||
| Dienst | Zweck | Datenschutz |
|
## Third-Party Services
|
||||||
| --- | --- | --- |
|
|
||||||
| Google Favicons API | Bookmark-Icons laden | Es wird nur die Domain übermittelt, keine vollständige URL |
|
|
||||||
| Google Fonts | Schriftarten (Rajdhani, Inter, Cinzel) | Standardmäßige Google-Fonts-Nutzungsbedingungen |
|
|
||||||
|
|
||||||
## Änderungen
|
| Service | Purpose | Privacy |
|
||||||
|
|---|---|---|
|
||||||
|
| Google Favicons API | Load bookmark icons | Only the domain is transmitted, not the full URL |
|
||||||
|
|
||||||
Der Entwickler behält sich das Recht vor, diese Extension jederzeit zu ändern, zu aktualisieren oder einzustellen, ohne vorherige Ankündigung.
|
## Trademark
|
||||||
|
|
||||||
## Kontakt
|
The name "Hellion Online Media", the associated logo and all related graphics are
|
||||||
|
the property of Florian Wathling / Hellion Online Media and may not be used without
|
||||||
|
explicit permission. The CC BY-NC-SA 4.0 license applies to the source code and
|
||||||
|
content of this project, not to trademarks or brand assets.
|
||||||
|
|
||||||
| | |
|
Forks and derivative works must remove or replace all Hellion Online Media branding.
|
||||||
| --- | --- |
|
|
||||||
| **Entwickler** | Florian Wathling |
|
## Legal
|
||||||
| **Unternehmen** | Hellion Online Media |
|
|
||||||
| **Web** | [hellion-media.de](https://hellion-media.de) |
|
This extension is developed and maintained by Florian Wathling / Hellion Online Media,
|
||||||
| **E-Mail** | [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de) |
|
based in Bad Harzburg, Germany. All matters are handled in accordance with German
|
||||||
| **Impressum** | [hellion-media.de/impressum](https://hellion-media.de/impressum) |
|
and EU law, including the General Data Protection Regulation (GDPR / DSGVO).
|
||||||
|
|
||||||
|
For legal inquiries: [hellion-media.de/impressum](https://hellion-media.de/impressum)
|
||||||
|
|
||||||
|
## Use of AI
|
||||||
|
|
||||||
|
**Claude:** Code analysis, bug fixing, documentation and proofreading.
|
||||||
|
**Me:** Architecture, features and logic are planned, thought through and written by me.
|
||||||
|
|
||||||
|
Who looks for "AI patterns" in the code: clean indentation is the linter,
|
||||||
|
okayish variable names are the developer, and the semicolon hiding somewhere
|
||||||
|
is what Claude finds. That's how it works.
|
||||||
|
|
||||||
|
I have ADHD and mild dyslexia. Claude helps me stay focused and makes sure
|
||||||
|
others can follow the code too. That's exactly what open source is for.
|
||||||
|
|
||||||
|
Source code is open, every decision is traceable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Developer** | Florian Wathling |
|
||||||
|
| **Company** | Hellion Online Media |
|
||||||
|
| **Web** | [hellion-media.de](https://hellion-media.de) |
|
||||||
|
| **Imprint** | [hellion-media.de/impressum](https://hellion-media.de/impressum) |
|
||||||
|
| **Contact** | [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Hellion NewTab** — [Hellion Online Media — Florian Wathling](https://hellion-media.de) — JonKazama-Hellion
|
||||||
|
|||||||
@@ -1,33 +1,34 @@
|
|||||||
# ⬡ Hellion NewTab v1.2.0
|
# ⬡ Hellion Dashboard v1.9.0
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
[](https://ko-fi.com/hellionmedia)
|
||||||
|
|
||||||
**Kein Account. Kein Abo. Keine Cloud. Alle Daten bleiben 100% lokal.**
|
**No account. No subscription. No cloud. All data stays 100% local.**
|
||||||
|
|
||||||
Ein persönlicher Bookmark-Dashboard als Browser-Extension.
|
A personal bookmark dashboard as a browser extension.
|
||||||
Boards, Drag & Drop, 8 Themes, Suchleiste, Sticky Notes — alles im Browser, alles offline.
|
Boards, drag & drop, 11 themes, search bar, widget system with notes, calculator, timer and more. All in the browser, all offline.
|
||||||
Keine externe Datenübertragung, keine Tracker, keine Analytics, keine Werbung.
|
No external data transmission, no trackers, no analytics, no ads.
|
||||||
|
|
||||||
Entwickelt von **[Hellion Online Media](https://hellion-media.de)** — JonKazama-Hellion.
|
Developed by **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Was diese Extension NICHT ist
|
## What this extension is NOT
|
||||||
|
|
||||||
- Kein Cloud-Sync und kein Account-System
|
- No cloud sync and no account system
|
||||||
- Keine Datenerfassung oder Telemetrie
|
- No data collection or telemetry
|
||||||
- Keine Drittanbieter-Abhängigkeiten oder Build-Tools
|
- No third-party dependencies or build tools
|
||||||
- Kein Netzwerkverkehr außer Favicon-Abruf (Google Favicons API)
|
- No network traffic except favicon fetching (Google Favicons API)
|
||||||
|
|
||||||
## Was diese Extension IST
|
## What this extension IS
|
||||||
|
|
||||||
Ein lokaler, privater NewTab-Ersatz für alle gängigen Browser.
|
A local, private NewTab replacement for all major browsers.
|
||||||
Bookmarks werden in `chrome.storage.local` gespeichert — nichts verlässt den Browser.
|
Bookmarks are stored in `chrome.storage.local`, nothing leaves the browser.
|
||||||
Was angezeigt wird, ist was gespeichert ist. Keine Magie.
|
What you see is what's saved. No magic.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,206 +36,247 @@ Was angezeigt wird, ist was gespeichert ist. Keine Magie.
|
|||||||
|
|
||||||
### Boards & Bookmarks
|
### Boards & Bookmarks
|
||||||
|
|
||||||
- Boards als Gruppen für Links — per Drag & Drop umsortierbar
|
- Boards as groups for links, sortable via drag & drop
|
||||||
- Bookmarks mit Favicon, Titel, optionaler Beschreibung
|
- Bookmarks with favicon, title and optional description
|
||||||
- Boards per Blur-Button verstecken (Privat-Modus)
|
- Hide boards with the blur button (privacy mode)
|
||||||
- HTML-Import von Browser-Lesezeichen (Chrome, Edge, Firefox)
|
- HTML import from browser bookmarks (Chrome, Edge, Firefox)
|
||||||
- JSON Export & Import (Backup & Restore)
|
- JSON export & import (backup & restore)
|
||||||
|
|
||||||
### Suchleiste
|
### Search Bar
|
||||||
|
|
||||||
- Google, DuckDuckGo oder Bing — per Klick wechselbar
|
- Google, DuckDuckGo or Bing, switchable with a click
|
||||||
- Ein/ausblendbar über Settings
|
- Toggleable via Settings
|
||||||
|
|
||||||
### Sticky Note
|
### Widget System
|
||||||
|
|
||||||
- Schwebendes Notiz-Widget, frei positionierbar
|
- **Notes & Checklists** — Floating note widgets with text or checklist template (max. 5)
|
||||||
- Text und Position werden persistent gespeichert
|
- **Calculator** — Shunting-yard parser (no `eval()`), history, keyboard input
|
||||||
|
- **Timer / Countdown** — Saveable presets, Web Audio API alarm, mute toggle, tab title blinks on completion
|
||||||
|
- **Image Reference** — Images as floating reference widgets, Canvas API WebP conversion (max. 3, enable in Settings)
|
||||||
|
- **Notebook Sidebar** — All notes at a glance
|
||||||
|
- **Widget Toolbar** — Floating buttons for quick access, position (left/right) configurable in Settings
|
||||||
|
- All widgets: draggable, resizable, z-index stacking on click
|
||||||
|
|
||||||
### 8 Themes
|
### 11 Themes
|
||||||
|
|
||||||
| Theme | Akzent | Stil |
|
| Theme | Accent | Style |
|
||||||
| --- | --- | --- |
|
|---|---|---|
|
||||||
| Nebula | 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 |
|
||||||
|
| Satisfactory | `#00b4d8` Cyan | Industrial Desert |
|
||||||
|
| Avorion | `#2ec4a0` Turquoise | Deep Void |
|
||||||
|
| Hellion Stealth | `#5ec2ff` Tech Blue | Tactical Recon |
|
||||||
|
|
||||||
### Bild-Credits
|
### Image Credits
|
||||||
|
|
||||||
| Theme | Quelle | Lizenz |
|
| Theme | Source | License |
|
||||||
| --- | --- | --- |
|
|---|---|---|
|
||||||
| Nebula | [Temel / mrwashingt0n](https://pixabay.com/de/users/mrwashingt0n-15745216/) auf Pixabay | Pixabay License (frei) |
|
| Nebula | [Temel / mrwashingt0n](https://pixabay.com/de/users/mrwashingt0n-15745216/) on Pixabay | Pixabay License (free) |
|
||||||
| Crescent | [Daniil Silantev](https://unsplash.com) auf Unsplash | Unsplash License (frei) |
|
| Crescent | [Daniil Silantev](https://unsplash.com) on Unsplash | Unsplash License (free) |
|
||||||
| Event Horizon | Eigenes Werk — Stillframe von [hellion-initiative.online](https://hellion-initiative.online) | Hellion Online Media |
|
| Event Horizon | Own work, still frame from [hellion-initiative.online](https://hellion-initiative.online) | Hellion Online Media |
|
||||||
| Merchantman | [Roberts Space Industries](https://robertsspaceindustries.com) — Made by the community | RSI Community Content |
|
| Merchantman | [Roberts Space Industries](https://robertsspaceindustries.com), made by the community | RSI Community Content |
|
||||||
| SC Sunset | Screenshot aus Star Citizen von Cloud Imperium Games | Fan Content |
|
| SC Sunset | Screenshot from Star Citizen by Cloud Imperium Games | Fan Content |
|
||||||
| Julia & Jin | Eigenes Werk — Final Fantasy XIV Screenshot, bearbeitet in Photoshop | Hellion Online Media |
|
| Julia & Jin | Own work, Final Fantasy XIV screenshot, edited in Photoshop | Hellion Online Media |
|
||||||
| Hellion HUD | Eigenes Werk — AI-generiert und nachbearbeitet für hellion-media.de | Hellion Online Media |
|
| Hellion HUD | Own work, AI-generated and post-processed for hellion-media.de | Hellion Online Media |
|
||||||
| Hellion Energy | Eigenes Werk — AI-generiert für hellion-media.de | Hellion Online Media |
|
| Hellion Energy | Own work, AI-generated for hellion-media.de | Hellion Online Media |
|
||||||
|
| Satisfactory | Screenshot from Satisfactory by Coffee Stain Studios | Fan Content |
|
||||||
|
| Avorion | Own work, screenshot from Avorion, Hellion Initiative ship | Hellion Online Media |
|
||||||
|
| Hellion Stealth | Screenshot from Star Citizen by Cloud Imperium Games | Fan Content |
|
||||||
|
|
||||||
### Settings
|
### Onboarding & Dialogs
|
||||||
|
|
||||||
- Compact Mode — reduziert Abstände für mehr Bookmarks
|
- 7-step welcome flow on first launch with widget explanation and optional gaming starter board
|
||||||
- Shorten Titles — kürzt lange Titel auf eine Zeile
|
- Custom frosted glass dialogs instead of native browser popups
|
||||||
- Open in New Tab — Bookmarks in neuem Tab öffnen
|
- Backup reminder every 7 days (warns about data loss on browser reset)
|
||||||
- Show Descriptions — Beschreibungen unter Bookmarks anzeigen
|
|
||||||
- Hide Extra Bookmarks — Boards ab 5/10/20 Bookmarks einklappen
|
### Appearance & Settings
|
||||||
- Hintergrundbild — URL oder lokaler Upload
|
|
||||||
- Suchleiste ein/ausblenden
|
- **Appearance modal** (header button), theme picker, background image and all display options in one modal
|
||||||
- JSON Export / Import
|
- **Settings panel** (header button), widgets, data & help, danger zone
|
||||||
- Danger Zone — Reset aller Daten
|
- **About footer**, developer info, license and support links permanently visible
|
||||||
|
- Compact mode, shorten titles, search bar toggle, open links in new tab, descriptions, hide extra bookmarks
|
||||||
|
- JSON export & import (backup & restore)
|
||||||
|
- Onboarding repeatable
|
||||||
|
- All UI labels in German (English coming in v2.1)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Browser-Kompatibilität
|
## Browser Compatibility
|
||||||
|
|
||||||
| Browser | Status | Manifest |
|
| Browser | Status | Manifest |
|
||||||
| --- | --- | --- |
|
|---|---|---|
|
||||||
| Chrome | Kompatibel | V3 (`manifest.json`) |
|
| Chrome | ✅ Compatible | V3 (`manifest.json`) |
|
||||||
| Edge | Kompatibel | V3 (`manifest.json`) |
|
| Edge | ✅ Compatible | V3 (`manifest.json`) |
|
||||||
| Brave | Kompatibel | V3 (`manifest.json`) |
|
| Brave | ✅ Compatible | V3 (`manifest.json`) |
|
||||||
| Opera | Kompatibel | V3 (`manifest.json`) |
|
| Opera | ✅ Compatible | V3 (`manifest.opera.json`) |
|
||||||
| Opera GX | Kompatibel | V3 (`manifest.json`) |
|
| Opera GX | ✅ Compatible | V3 (`manifest.opera.json`) |
|
||||||
| Vivaldi | Kompatibel | V3 (`manifest.json`) |
|
| Vivaldi | ✅ Compatible | V3 (`manifest.json`) |
|
||||||
| Firefox | Kompatibel | V2 (`manifest.firefox.json`) |
|
| Firefox | ✅ Compatible | V3 (`manifest.firefox.json`) |
|
||||||
|
|
||||||
> **Firefox-Hinweis:** Firefox verwendet aktuell Manifest V2. Mozilla arbeitet an MV3-Support —
|
> **Firefox note:** From v1.2.0 onwards the extension runs on Manifest V3, identical to Chrome/Edge.
|
||||||
> sobald stabil, wird die Extension migriert. MV2 wird von Mozilla weiterhin unterstützt.
|
> `manifest.firefox.json` remains a separate file for Firefox-specific adjustments.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Chrome / Edge / Brave / Opera / Opera GX / Vivaldi
|
### Chrome / Edge / Brave / Vivaldi
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. Repository als ZIP herunterladen oder git clone
|
1. Download the repository as ZIP or git clone
|
||||||
2. chrome://extensions öffnen (oder edge:// / brave:// / opera://)
|
2. Open chrome://extensions (or edge:// / brave://)
|
||||||
3. Entwicklermodus aktivieren
|
3. Enable developer mode
|
||||||
4. "Entpackte Erweiterung laden" → Ordner auswählen in dem manifest.json liegt
|
4. Click "Load unpacked" and select the folder containing manifest.json
|
||||||
5. Neuen Tab öffnen
|
5. Open a new tab
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Opera / Opera GX
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use manifest.opera.json as manifest.json:
|
||||||
|
copy manifest.opera.json manifest.json # Windows
|
||||||
|
cp manifest.opera.json manifest.json # Linux/Mac
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. Open opera://extensions
|
||||||
|
2. Enable developer mode
|
||||||
|
3. Click "Load unpacked" and select the folder
|
||||||
|
4. Open a new tab
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Opera note:** Opera GX prioritizes Speed Dial, the included workaround
|
||||||
|
> takes over the new tab page reliably. Details: [src/js/opera/README.md](src/js/opera/README.md)
|
||||||
|
|
||||||
### Firefox
|
### Firefox
|
||||||
|
|
||||||
Firefox benötigt `manifest.json` im Format V2.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# manifest.json durch Firefox-Version ersetzen:
|
# Use manifest.firefox.json as manifest.json:
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. about:debugging#/runtime/this-firefox öffnen
|
1. Open about:debugging#/runtime/this-firefox
|
||||||
2. "Temporäres Add-on laden"
|
2. Click "Load Temporary Add-on"
|
||||||
3. Die manifest.json aus dem Projektordner auswählen
|
3. Select the manifest.json from the project folder
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Hinweis:** Temporäre Add-ons werden beim Browser-Neustart entfernt.
|
> **Note:** Temporary add-ons are removed on browser restart.
|
||||||
> Für dauerhafte Installation ist eine signierte `.xpi`-Datei nötig.
|
> For permanent installation a signed `.xpi` file is required.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Browser-Bookmarks exportieren & importieren
|
## Importing Browser Bookmarks
|
||||||
|
|
||||||
| Browser | Export-Pfad |
|
| Browser | Export path |
|
||||||
| --- | --- |
|
|---|---|
|
||||||
| Chrome / Edge | Einstellungen → Lesezeichen → Exportieren |
|
| Chrome / Edge | Settings → Bookmarks → Export bookmarks |
|
||||||
| Firefox | Lesezeichen → Alle Lesezeichen → Importieren und Sichern → Als HTML exportieren |
|
| Firefox | Bookmarks → All Bookmarks → Import and Backup → Export Bookmarks to HTML |
|
||||||
|
|
||||||
Die exportierte `.html`-Datei über den **Import**-Button in der Extension laden.
|
Load the exported `.html` file via the **Import** button in the extension.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Datenschutz
|
## Privacy
|
||||||
|
|
||||||
- Keine externe Datenübertragung (außer Google Favicons API für Icons)
|
- No external data transmission (except Google Favicons API for icons)
|
||||||
- Speicherung in `chrome.storage.local` (Chromium) bzw. `browser.storage.local` (Firefox)
|
- Storage in `chrome.storage.local` (Chromium) or `browser.storage.local` (Firefox)
|
||||||
- Keine Tracker, keine Analytics, keine Werbung
|
- No trackers, no analytics, no ads
|
||||||
- Keine Cookies, keine Session-Daten
|
- No cookies, no session data
|
||||||
- Storage-Quota-Prüfung warnt bei 8 MB+ (Limit: 10 MB)
|
- Storage quota check warns at 8 MB+ (limit: 10 MB)
|
||||||
- Permissions: `storage`, `bookmarks`
|
- Permissions: `storage`, `bookmarks` (all browsers) + `tabs` (Opera / Opera GX only)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech-Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Komponente | Details |
|
| Component | Details |
|
||||||
| --- | --- |
|
|---|---|
|
||||||
| Sprache | JavaScript (Vanilla ES2020, keine Frameworks) |
|
| Language | JavaScript (Vanilla ES2020, no frameworks) |
|
||||||
| Styling | CSS Custom Properties (Theme-System) |
|
| Styling | CSS Custom Properties (theme system) |
|
||||||
| Fonts | Google Fonts (Rajdhani, Inter, Cinzel) |
|
| Fonts | Local 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 (native) |
|
||||||
| Build | Kein Build-Schritt — direkt lauffähig |
|
| Build | No build step, runs directly |
|
||||||
| CI/CD | GitHub Actions (Security, Quality, Release) |
|
| CI/CD | GitHub Actions (security, quality, release) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architektur
|
## Architecture
|
||||||
|
|
||||||
```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)
|
||||||
├── newtab.html # Haupt-HTML (UI-Struktur, Modals, Settings Panel)
|
├── manifest.opera.json # Opera / Opera GX (MV3 + workaround)
|
||||||
|
├── newtab.html # Main HTML (UI structure, modals, settings panel)
|
||||||
├── LICENSE # CC BY-NC-SA 4.0
|
├── LICENSE # CC BY-NC-SA 4.0
|
||||||
├── SECURITY.md # Sicherheitsrichtlinie und Meldeprozess
|
├── CHANGELOG.md # Version history
|
||||||
├── DISCLAIMER.md # Haftungsausschluss
|
├── SECURITY.md # Security policy and reporting
|
||||||
|
├── DISCLAIMER.md # Disclaimer and legal
|
||||||
│
|
│
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ ├── storage.js # Storage Abstraction + Quota-Prüfung
|
│ │ ├── storage.js # Storage abstraction + quota check
|
||||||
│ │ ├── state.js # Globaler State, Defaults, Hilfsfunktionen
|
│ │ ├── state.js # Global state, defaults, helpers
|
||||||
│ │ ├── themes.js # Theme-Definitionen & Anwendungslogik
|
│ │ ├── dialog.js # Custom dialog system (HellionDialog.alert/confirm)
|
||||||
│ │ ├── boards.js # Board/Bookmark Rendering, Event Delegation, Modals
|
│ │ ├── themes.js # Theme definitions & application (11 themes)
|
||||||
│ │ ├── drag.js # Drag & Drop (Pointer Events, Board + Bookmark)
|
│ │ ├── boards.js # Board/bookmark rendering, event delegation, modals
|
||||||
│ │ ├── settings.js # Settings Panel (Toggles, Theme-Picker, Background)
|
│ │ ├── drag.js # Drag & drop (Pointer Events, board + bookmark)
|
||||||
│ │ ├── search.js # Suchleiste (Google, DuckDuckGo, Bing)
|
│ │ ├── settings.js # Settings panel, appearance modal, accordion
|
||||||
│ │ ├── sticky.js # Sticky Note Widget (Drag, Persist, Toggle)
|
│ │ ├── search.js # Search bar (Google, DuckDuckGo, Bing)
|
||||||
│ │ ├── data.js # JSON Export / Import mit Validierung
|
│ │ ├── widgets.js # Widget manager (registry, drag, resize, z-index)
|
||||||
│ │ └── app.js # Init, Clock, globale Events (Einstiegspunkt)
|
│ │ ├── notes.js # Notes & checklists (multi-instance, max. 5)
|
||||||
|
│ │ ├── calculator.js # Calculator (shunting-yard, history)
|
||||||
|
│ │ ├── timer.js # Timer/countdown (presets, Web Audio alarm)
|
||||||
|
│ │ ├── image-ref.js # Image reference widget (Canvas API, sessionStorage)
|
||||||
|
│ │ ├── data.js # JSON export / import with validation
|
||||||
|
│ │ ├── onboarding.js # 7-step welcome flow + gaming board
|
||||||
|
│ │ ├── app.js # Init, clock, global events (entry point)
|
||||||
|
│ │ └── opera/ # Opera GX workaround scripts
|
||||||
|
│ │ ├── background.js # Tab management against Speed Dial
|
||||||
|
│ │ └── redirect.js # Content script redirect
|
||||||
│ └── css/
|
│ └── css/
|
||||||
│ └── main.css # Styles + Theme-System + Responsive Breakpoints
|
│ └── main.css # Styles + 11 themes + responsive breakpoints
|
||||||
│
|
│
|
||||||
├── assets/
|
├── assets/
|
||||||
│ ├── themes/ # 8 Theme-Hintergrundbilder
|
│ ├── fonts/ # Local fonts (Rajdhani, Inter, Cinzel)
|
||||||
│ │ ├── bg-nebula.jpg
|
│ ├── themes/ # 11 theme background images (WebP only)
|
||||||
│ │ ├── 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
|
||||||
│ └── icon128.png
|
│ └── icon128.png
|
||||||
│
|
│
|
||||||
|
├── docs/
|
||||||
|
│ ├── architecture.md # Project architecture and init sequence
|
||||||
|
│ ├── widget-schema.md # Widget system API and schema reference
|
||||||
|
│ ├── patterns.md # Code patterns and conventions
|
||||||
|
│ └── style-guide.md # Design system and theme documentation
|
||||||
|
│
|
||||||
└── .github/
|
└── .github/
|
||||||
└── workflows/
|
└── workflows/
|
||||||
├── security.yml # CodeQL-Analyse + Dependency Review
|
├── security.yml # CodeQL analysis + dependency review
|
||||||
├── quality.yml # Struktur, Manifest, Syntax, Versions-Konsistenz
|
├── quality.yml # Structure, manifest, syntax, version consistency
|
||||||
└── release.yml # ZIP-Pakete (Chrome + Firefox) + SHA256 Checksummen
|
└── release.yml # ZIP packages (Chrome + Firefox + Opera) + SHA256
|
||||||
```
|
```
|
||||||
|
|
||||||
### Design-Prinzipien
|
### Design Principles
|
||||||
|
|
||||||
- **Zero Dependencies** — Kein npm, kein Build, kein Framework. Direkt lauffähig
|
- **Zero Dependencies** — No npm, no build, no framework. Runs directly
|
||||||
- **Privacy First** — Alle Daten lokal, kein Server-Kontakt
|
- **Privacy First** — All data local, no server contact
|
||||||
- **Modular** — 10 JS-Dateien mit klarer Zuständigkeit
|
- **Modular** — 15 JS files with clear responsibilities
|
||||||
- **Responsive** — Tablet (768px) und Smartphone (480px) Breakpoints
|
- **Responsive** — Tablet (768px) and smartphone (480px) breakpoints
|
||||||
- **Secure** — createElement statt innerHTML, URL-Validierung, Storage-Fehlerbehandlung
|
- **Secure** — `createElement` instead of `innerHTML`, URL validation, storage error handling
|
||||||
- **Event Delegation** — Ein Listener pro Board-Liste statt pro Bookmark (Performance)
|
- **Event Delegation** — One listener per board list instead of per bookmark (performance)
|
||||||
- **Theme-System** — CSS Custom Properties, 8 Themes, Custom-Background-Support
|
- **Theme System** — CSS Custom Properties, 11 themes, custom background support
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -242,126 +284,88 @@ hellion-newtab/
|
|||||||
|
|
||||||
### Security Scan (`security.yml`)
|
### Security Scan (`security.yml`)
|
||||||
|
|
||||||
- **CodeQL-Analyse** — Statische Sicherheitsanalyse für JavaScript
|
- **CodeQL analysis** — Static security analysis for JavaScript
|
||||||
- **Dependency Review** — Prüft Pull Requests auf bekannte Schwachstellen
|
- **Dependency review** — Checks pull requests for known vulnerabilities
|
||||||
- **Zeitplan** — Automatisch wöchentlich (Montag 06:00 UTC) + bei Push/PR
|
- **Schedule** — Automatically weekly (Monday 06:00 UTC) + on push/PR
|
||||||
|
|
||||||
### Code Quality (`quality.yml`)
|
### Code Quality (`quality.yml`)
|
||||||
|
|
||||||
- **Projektstruktur** — Alle Pflichtdateien und -ordner vorhanden
|
- **Project structure** — All required files and folders present
|
||||||
- **Manifest-Validierung** — JSON-Syntax, Version, Permissions
|
- **Manifest validation** — JSON syntax, version, permissions
|
||||||
- **JavaScript Syntax-Check** — `node --check` für alle JS-Dateien
|
- **JavaScript syntax check** — `node --check` for all JS files
|
||||||
- **Versions-Konsistenz** — manifest.json, manifest.firefox.json und newtab.html müssen übereinstimmen
|
- **Version consistency** — manifest.json, manifest.firefox.json and newtab.html must match
|
||||||
- **Icon-Prüfung** — Alle Extension-Icons vorhanden
|
- **Icon check** — All extension icons present
|
||||||
|
|
||||||
### Release (`release.yml`)
|
### Release (`release.yml`)
|
||||||
|
|
||||||
- **Trigger** — Bei Git-Tag (`v*`)
|
- **Trigger** — On Git tag (`v*`)
|
||||||
- **Pakete** — Chrome-ZIP (MV3) + Firefox-ZIP (MV2)
|
- **Packages** — Chrome ZIP + Firefox ZIP + Opera ZIP (all MV3)
|
||||||
- **Checksummen** — SHA256 für alle Artefakte
|
- **Checksums** — SHA256 for all artifacts
|
||||||
- **GitHub Release** — Automatisch mit Installationsanleitung
|
- **GitHub Release** — Automatic with installation instructions
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Release erstellen:
|
# Create a release:
|
||||||
git tag v1.2.0
|
git tag v1.10.0
|
||||||
git push origin v1.2.0
|
git push origin v1.10.0
|
||||||
# → GitHub Action erstellt automatisch Release mit ZIP-Dateien
|
# → GitHub Action automatically creates release with ZIP files
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Entwicklung
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Repository klonen
|
# Clone the repository
|
||||||
git clone https://github.com/JonKazama-Hellion/Hellion-NewTab.git
|
git clone https://github.com/JonKazama-Hellion/Hellion-NewTab.git
|
||||||
|
|
||||||
# Extension im Browser laden (siehe Installation)
|
# Load the extension in your browser (see Installation)
|
||||||
|
|
||||||
# Nach Änderungen: Extension neu laden
|
# After changes: reload the extension
|
||||||
chrome://extensions → Hellion NewTab → Neu laden
|
chrome://extensions → Hellion NewTab → Reload
|
||||||
```
|
```
|
||||||
|
|
||||||
Kein Build-Schritt nötig. Dateien ändern, Extension neu laden, fertig.
|
No build step needed. Change files, reload extension, done.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sicherheit
|
## Security
|
||||||
|
|
||||||
Sicherheitslücken bitte **nicht** über öffentliche Issues melden.
|
Please do **not** report security vulnerabilities through public GitHub issues.
|
||||||
Details zur Meldung, Reaktionszeiten und Sicherheitsarchitektur: [SECURITY.md](SECURITY.md)
|
Details on reporting, response times and security architecture: [SECURITY.md](SECURITY.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lizenz & Impressum
|
## License & Legal
|
||||||
|
|
||||||
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
|
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
|
||||||
|
|
||||||
- Kostenlos für private Nutzung
|
- Free for private use
|
||||||
- Teilen und Modifikation erlaubt mit Namensnennung
|
- Sharing and modification allowed with attribution
|
||||||
- Kommerzielle Nutzung ohne Erlaubnis verboten
|
- Commercial use without permission prohibited
|
||||||
|
|
||||||
Vollständige Lizenz: [LICENSE](LICENSE) | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/)
|
Full license: [LICENSE](LICENSE) | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/)
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
| --- | --- |
|
|---|---|
|
||||||
| **Entwickler** | Florian Wathling |
|
| **Developer** | Florian Wathling |
|
||||||
| **Unternehmen** | Hellion Online Media |
|
| **Company** | 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) |
|
| **Imprint** | [hellion-media.de/impressum](https://hellion-media.de/impressum) |
|
||||||
| **Bug Reports** | [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=Hellion%20NewTab%20%E2%80%93%20Bug%20Report) |
|
| **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
|
### Use of AI
|
||||||
|
|
||||||
### v1.2.0 — 20.03.2026
|
**Claude:** Code analysis, bug fixing, documentation and proofreading.
|
||||||
|
**Me:** Architecture, features and logic are planned, thought through and written by me.
|
||||||
|
|
||||||
- Projektstruktur in `src/js/`, `src/css/`, `assets/` aufgeteilt
|
Details: [DISCLAIMER.md](DISCLAIMER.md)
|
||||||
- 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Einsatz von AI
|
> Full version history: [CHANGELOG.md](CHANGELOG.md)
|
||||||
|
|
||||||
AI (Claude Code, Opus 4.6 von Anthropic) wurde als Hilfsmittel eingesetzt — für Fehleridentifikation, Code-Review und Qualitätssicherung. Architektur, Features und alle Entscheidungen sind Eigenleistung.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
|
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
|
||||||
|
|||||||
@@ -1,76 +1,92 @@
|
|||||||
# Sicherheitsrichtlinie — Hellion NewTab
|
# Security Policy — Hellion NewTab
|
||||||
|
|
||||||
## Unterstützte Versionen
|
## Supported Versions
|
||||||
|
|
||||||
| Version | Status |
|
| Version | Status |
|
||||||
| --- | --- |
|
|---|---|
|
||||||
| 1.2.x | Aktiv unterstützt |
|
| 1.9.x | Actively supported |
|
||||||
| < 1.2.0 | Nicht unterstützt |
|
| < 1.9.0 | Not supported |
|
||||||
|
|
||||||
## Sicherheitslücke melden
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Wenn du eine Sicherheitslücke in Hellion NewTab findest, melde sie bitte **nicht** über ein öffentliches GitHub Issue.
|
If you find a security vulnerability in Hellion NewTab, please **do not** open a public GitHub issue.
|
||||||
|
|
||||||
### Kontakt
|
### Contact
|
||||||
|
|
||||||
**E-Mail:** [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=Hellion%20NewTab%20%E2%80%93%20Security%20Report)
|
**Email:** [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=Hellion%20NewTab%20%E2%80%93%20Security%20Report)
|
||||||
|
|
||||||
Bitte folgende Informationen angeben:
|
Please include the following information:
|
||||||
|
|
||||||
- Beschreibung der Schwachstelle
|
- Description of the vulnerability
|
||||||
- Schritte zur Reproduktion
|
- Steps to reproduce
|
||||||
- Betroffene Version(en)
|
- Affected version(s)
|
||||||
- Mögliche Auswirkungen (Datenverlust, XSS, etc.)
|
- Potential impact (data loss, XSS, etc.)
|
||||||
|
|
||||||
### Reaktionszeit
|
### Response Times
|
||||||
|
|
||||||
- **Bestätigung:** Innerhalb von 48 Stunden
|
- **Acknowledgement:** Within 48 hours
|
||||||
- **Ersteinschätzung:** Innerhalb von 7 Tagen
|
- **Initial assessment:** Within 7 days
|
||||||
- **Fix:** Abhängig von Schweregrad, Ziel innerhalb von 14 Tagen
|
- **Fix:** Depends on severity, target within 14 days
|
||||||
|
|
||||||
### Schweregrad-Einstufung
|
### Severity Levels
|
||||||
|
|
||||||
| Stufe | Beschreibung | Beispiel |
|
| Level | Description | Example |
|
||||||
| --- | --- | --- |
|
|---|---|---|
|
||||||
| Kritisch | Datenverlust oder Remote Code Execution | Storage-Manipulation durch Dritte |
|
| Critical | Data loss or remote code execution | Storage manipulation by third parties |
|
||||||
| Hoch | XSS oder ungewollte Datenübertragung | Script-Injection via Bookmark-Import |
|
| High | XSS or unintended data transmission | Script injection via bookmark import |
|
||||||
| Mittel | Umgehung von UI-Schutzmechanismen | Blur-Bypass, Settings-Manipulation |
|
| Medium | UI protection bypass | Blur bypass, settings manipulation |
|
||||||
| Niedrig | Kosmetisch oder theoretisch | Edge-Cases ohne praktische Auswirkung |
|
| Low | Cosmetic or theoretical | Edge cases without practical impact |
|
||||||
|
|
||||||
## Sicherheitsarchitektur
|
|
||||||
|
|
||||||
### Datenverarbeitung
|
|
||||||
|
|
||||||
- **Keine externe Datenübertragung** — Alle Daten bleiben in `chrome.storage.local`
|
|
||||||
- **Kein Server-Kontakt** — Außer Google Favicons API für Bookmark-Icons
|
|
||||||
- **Keine Cookies, Sessions oder Tokens**
|
|
||||||
- **Kein Netzwerkzugriff** außer Favicon-Abruf
|
|
||||||
|
|
||||||
### Eingabe-Validierung
|
|
||||||
|
|
||||||
- URL-Validierung bei Bookmark-Erstellung (`new URL()`)
|
|
||||||
- JSON-Import: Board- und Bookmark-Struktur wird validiert
|
|
||||||
- HTML-Sanitierung via `escHtml()` und `createElement` (kein `innerHTML` für User-Daten)
|
|
||||||
- Storage-Quota-Prüfung mit Warnung bei 8 MB+
|
|
||||||
|
|
||||||
### Permissions
|
|
||||||
|
|
||||||
Diese Extension benötigt nur zwei Browser-Permissions:
|
|
||||||
|
|
||||||
| Permission | Grund |
|
|
||||||
| --- | --- |
|
|
||||||
| `storage` | Boards, Settings und Sticky Note lokal speichern |
|
|
||||||
| `bookmarks` | Browser-Lesezeichen für HTML-Import lesen |
|
|
||||||
|
|
||||||
Keine Permissions für: Tabs, History, Web Requests, Downloads, Clipboard oder Host-Zugriff.
|
|
||||||
|
|
||||||
### CI/CD-Sicherheit
|
|
||||||
|
|
||||||
- **CodeQL** — Automatische statische Analyse bei Push und PR
|
|
||||||
- **Dependency Review** — Prüft auf bekannte Schwachstellen in PRs
|
|
||||||
- **Wöchentlicher Scan** — Automatischer CodeQL-Lauf jeden Montag
|
|
||||||
- **SHA256-Checksummen** — Alle Release-Artefakte werden signiert
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
|
## Security Architecture
|
||||||
|
|
||||||
|
### Data Handling
|
||||||
|
|
||||||
|
- **No external data transmission** — all data stays in `chrome.storage.local`
|
||||||
|
- **No server contact** — except Google Favicons API for bookmark icons
|
||||||
|
- **No cookies, sessions or tokens**
|
||||||
|
- **No network access** beyond favicon fetching
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
- URL validation on bookmark creation (`new URL()`)
|
||||||
|
- JSON import validates board and bookmark structure before applying
|
||||||
|
- HTML sanitization via `escHtml()` and `createElement` — no `innerHTML` for user data
|
||||||
|
- Storage quota check with warning at 8 MB+
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
This extension requests the following browser permissions:
|
||||||
|
|
||||||
|
| Permission | Browsers | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `storage` | All | Store boards, settings and widget states locally |
|
||||||
|
| `bookmarks` | All | Read browser bookmarks for direct import |
|
||||||
|
| `tabs` | Opera / Opera GX only | Required for the Speed Dial workaround — `background.js` monitors tab URLs and redirects via `chrome.tabs.update` |
|
||||||
|
|
||||||
|
No permissions requested for: history, web requests, downloads, clipboard or host access.
|
||||||
|
|
||||||
|
### CI/CD Security
|
||||||
|
|
||||||
|
- **CodeQL** — Automatic static analysis on every push and PR
|
||||||
|
- **Dependency Review** — Checks for known vulnerabilities in PRs
|
||||||
|
- **Weekly scan** — Automated CodeQL run every Monday at 06:00 UTC
|
||||||
|
- **SHA256 checksums** — All release artifacts are checksummed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legal
|
||||||
|
|
||||||
|
Hellion NewTab is developed and maintained by **Florian Wathling / Hellion Online Media**,
|
||||||
|
based in Bad Harzburg, Germany.
|
||||||
|
|
||||||
|
All security matters are handled in accordance with **German and EU law**, including
|
||||||
|
the General Data Protection Regulation (GDPR / DSGVO). Users in the European Union
|
||||||
|
are covered by the same legal framework.
|
||||||
|
|
||||||
|
For legal inquiries: [hellion-media.de/impressum](https://hellion-media.de/impressum)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Hellion Dashboard** — [Hellion Online Media — Florian Wathling](https://hellion-media.de) — JonKazama-Hellion
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 672 B After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 109 B After Width: | Height: | Size: 827 B |
|
Before Width: | Height: | Size: 227 B After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 76 KiB |
@@ -0,0 +1,316 @@
|
|||||||
|
# Hellion Dashboard — Design & Theme System
|
||||||
|
|
||||||
|
> This document is intentionally written in English. Full German/English i18n support
|
||||||
|
> is planned for v2.0 — until then, English keeps the docs accessible to anyone
|
||||||
|
> who wants to contribute or fork the project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Pillars
|
||||||
|
|
||||||
|
| Pillar | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Immersion** | The interface feels like a HUD floating over the scene, not a foreign object sitting on top of it |
|
||||||
|
| **Visual Clarity** | Deliberate use of `blur` separates UI from background and reduces visual noise and cognitive load |
|
||||||
|
| **Harmony** | Every theme pulls its colors from the dominant light sources in its background image |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background Images — WebP Only
|
||||||
|
|
||||||
|
**All background images must be in WebP format.** This is an intentional architectural
|
||||||
|
decision to keep storage quota usage predictable and leave room for future features
|
||||||
|
(widgets, image references, etc.) that also compete for the 10 MB `chrome.storage` limit.
|
||||||
|
|
||||||
|
JPG, PNG and other formats are not accepted, so convert before adding a theme.
|
||||||
|
|
||||||
|
### Recommended Settings
|
||||||
|
|
||||||
|
| Quality | When to use |
|
||||||
|
|---|---|
|
||||||
|
| 85 | Default, good balance of size and sharpness |
|
||||||
|
| 80 | For images over 500 KB |
|
||||||
|
| 90 | For images with fine details (stars, in-game UI text) |
|
||||||
|
|
||||||
|
### Conversion Tools
|
||||||
|
|
||||||
|
**Squoosh** (squoosh.app) — browser-based, no install, nothing gets uploaded to external servers.
|
||||||
|
Drag in the image, pick WebP, set quality to 85, download. Done.
|
||||||
|
|
||||||
|
**cwebp** (command line):
|
||||||
|
```bash
|
||||||
|
cwebp -q 85 input.jpg -o output.webp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Current Theme Images
|
||||||
|
|
||||||
|
| File | Status |
|
||||||
|
|---|---|
|
||||||
|
| `bg-nebula.webp` | ✅ WebP |
|
||||||
|
| `bg-crescent.webp` | ✅ WebP |
|
||||||
|
| `bg-event-horizon.webp` | ✅ WebP |
|
||||||
|
| `bg-merchantman.webp` | ✅ WebP |
|
||||||
|
| `bg-julia-jin.webp` | ✅ WebP |
|
||||||
|
| `bg-sc-sunset.webp` | ✅ WebP |
|
||||||
|
| `bg-hellion-hud.webp` | ✅ WebP |
|
||||||
|
| `bg-hellion-energy.webp` | ✅ WebP |
|
||||||
|
| `bg-satisfactory.webp` | ✅ WebP |
|
||||||
|
| `bg-avorion.webp` | ✅ WebP |
|
||||||
|
| `bg-scPolaris.webp` | ✅ WebP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anatomy of a Theme
|
||||||
|
|
||||||
|
Every theme lives in `main.css` as a `[data-theme="name"]` block. Copy this template
|
||||||
|
to add a new one:
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme="your-theme-name"] {
|
||||||
|
/* 1. ACCENTS — The light source */
|
||||||
|
--accent: #HEXCODE; /* Main color (neon/light) */
|
||||||
|
--accent-dim: rgba(R, G, B, 0.12); /* Subtle background tint */
|
||||||
|
--accent-glow: rgba(R, G, B, 0.08); /* Glow for logo & clock */
|
||||||
|
--border-accent: rgba(R, G, B, 0.25); /* Focus ring */
|
||||||
|
|
||||||
|
/* 2. BASE — The foundation */
|
||||||
|
--bg-primary: #HEXCODE; /* Darkest point in the image */
|
||||||
|
--bg-board: rgba(R, G, B, 0.55); /* Glass effect on boards */
|
||||||
|
--border: rgba(R, G, B, 0.12); /* Default border */
|
||||||
|
|
||||||
|
/* 3. TEXT — Contrast */
|
||||||
|
--text-primary: #FFFFFF; /* Readable, slightly tinted */
|
||||||
|
--text-secondary: #A0A0A0; /* Desaturated, less visual weight */
|
||||||
|
--text-muted: #606060; /* Barely visible, for hints */
|
||||||
|
|
||||||
|
/* 4. OVERLAY — Vignette */
|
||||||
|
--overlay-bg: radial-gradient(
|
||||||
|
circle at center,
|
||||||
|
transparent 0%,
|
||||||
|
var(--bg-primary) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* 5. COMPONENT COLORS */
|
||||||
|
--header-bg: rgba(R, G, B, 0.94);
|
||||||
|
--board-hover-border: rgba(R, G, B, 0.22);
|
||||||
|
--toggle-on-bg: rgba(R, G, B, 0.20);
|
||||||
|
--logo-shadow: rgba(R, G, B, 0.50);
|
||||||
|
|
||||||
|
/* 6. FONTS */
|
||||||
|
--font-display: 'Rajdhani', sans-serif;
|
||||||
|
--font-body: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme-specific overrides */
|
||||||
|
[data-theme="your-theme-name"] .logo { letter-spacing: 4px; }
|
||||||
|
[data-theme="your-theme-name"] .clock { color: var(--accent); }
|
||||||
|
[data-theme="your-theme-name"] .board-title { text-transform: uppercase; }
|
||||||
|
[data-theme="your-theme-name"] .board { backdrop-filter: blur(8px); }
|
||||||
|
[data-theme="your-theme-name"] .bm-item:hover { background: var(--accent-dim); }
|
||||||
|
```
|
||||||
|
|
||||||
|
After adding the CSS block, register the theme in `src/js/themes.js` and add a preview entry in the theme picker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Patterns
|
||||||
|
|
||||||
|
### Frosted Glass
|
||||||
|
|
||||||
|
Hardware-accelerated blur for readability on complex backgrounds:
|
||||||
|
|
||||||
|
```css
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates depth and visual calm behind text and UI elements. Standard value is `8px`. Only increase it when the background image has a lot of fine detail that competes with the UI.
|
||||||
|
|
||||||
|
### Clock Color
|
||||||
|
|
||||||
|
All themes set `color: var(--accent)` on the clock element. This is a consistent
|
||||||
|
detail across the entire theme system. Don't skip it for new themes.
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme="your-theme"] .clock { color: var(--accent); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography Hierarchy
|
||||||
|
|
||||||
|
| Font | Usage |
|
||||||
|
|---|---|
|
||||||
|
| **Rajdhani** | Display: clock, logo, titles. Anything that should feel like a system readout |
|
||||||
|
| **Inter** | Body: bookmark titles, lists, interactive elements |
|
||||||
|
| **Cinzel** | Fantasy: reserved for themes with a majestic or ancient aesthetic (Crescent, Julia & Jin) |
|
||||||
|
|
||||||
|
### Overlay Strategy
|
||||||
|
|
||||||
|
The overlay gradient determines what stays visible in the background image.
|
||||||
|
|
||||||
|
**Radial (default)** draws attention to the center and darkens edges:
|
||||||
|
```css
|
||||||
|
--overlay-bg: radial-gradient(circle at center, transparent 0%, var(--bg-primary) 100%);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linear** darkens top and bottom and leaves the middle open. Use when the subject
|
||||||
|
is horizontally centered and should stay visible (Satisfactory factory floor, SC Sunset horizon):
|
||||||
|
```css
|
||||||
|
--overlay-bg: linear-gradient(180deg, rgba(R,G,B,0.85) 0%, rgba(R,G,B,0.15) 50%, rgba(R,G,B,0.90) 100%);
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose based on where the most important part of the image is, not by habit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Focus & Accessibility
|
||||||
|
|
||||||
|
For backgrounds with a lot of detail (many small elements, high contrast, busy textures),
|
||||||
|
increase board alpha and blur to reduce visual noise. This makes boards easier to scan,
|
||||||
|
especially for users with ADHD or attention sensitivities.
|
||||||
|
|
||||||
|
```css
|
||||||
|
--bg-board: rgba(R, G, B, 0.65); /* Up from default 0.55 */
|
||||||
|
backdrop-filter: blur(12px); /* Up from default 8px */
|
||||||
|
```
|
||||||
|
|
||||||
|
This was applied intentionally to the Satisfactory theme, because the factory floor screenshot
|
||||||
|
has a lot going on and needed more visual separation between background and UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## All 11 Themes
|
||||||
|
|
||||||
|
| Theme | File | Accent | Mood | Overlay |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Nebula | `bg-nebula.webp` | `#b359ff` Magenta | Chill, Cosmic | Radial |
|
||||||
|
| Crescent | `bg-crescent.webp` | `#d4bd8a` Gold | Luxury, Night | Radial |
|
||||||
|
| Event Horizon | `bg-event-horizon.webp` | `#9d5cff` Purple | Deep Space, Void | Radial |
|
||||||
|
| Merchantman | `bg-merchantman.webp` | `#2eb8b8` Emerald | Industrial, Alien | Radial |
|
||||||
|
| Julia & Jin | `bg-julia-jin.webp` | `#7db3ff` Aetherial Blue | FFXIV Night | Linear |
|
||||||
|
| SC Sunset | `bg-sc-sunset.webp` | `#ff8c3d` Amber | Emotional, Horizon | Linear |
|
||||||
|
| Hellion HUD | `bg-hellion-hud.webp` | `#32ff6a` Neon Green | Tactical, Admin | Radial |
|
||||||
|
| Hellion Energy | `bg-hellion-energy.webp` | `#1eff8e` Acid Green | Overdrive, Power | Radial |
|
||||||
|
| Satisfactory | `bg-satisfactory.webp` | `#00b4d8` Cyan | Industrial Desert | Linear |
|
||||||
|
| Avorion | `bg-avorion.webp` | `#2ec4a0` Turquoise | Deep Void | Radial |
|
||||||
|
| Hellion Stealth | `bg-scPolaris.webp` | `#5ec2ff` Tech Blue | Tactical Recon | Radial |
|
||||||
|
|
||||||
|
### Theme Quirks Worth Knowing
|
||||||
|
|
||||||
|
**Julia & Jin** uses `Cinzel` as display font and a linear gradient. The subjects in
|
||||||
|
the screenshot are positioned left of center, so radial would soften them.
|
||||||
|
|
||||||
|
**Satisfactory** has increased board alpha (0.65) and stronger blur (12px), an intentional
|
||||||
|
ADHD optimization for a visually busy background.
|
||||||
|
|
||||||
|
**Avorion** uses `letter-spacing: 6px` on the logo for maximum HUD feel.
|
||||||
|
|
||||||
|
**Hellion Stealth** is the only theme with `border-left: 2px solid var(--accent)` on
|
||||||
|
`.bm-item:hover`. Every other theme uses background tinting only. This is intentional
|
||||||
|
and gives Stealth its tactical scanner character. Don't apply it to other themes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registering a Theme in themes.js
|
||||||
|
|
||||||
|
The `THEMES` object in `src/js/themes.js` is the single source of truth for which
|
||||||
|
themes exist and which background image they use. CSS handles all the visual variables —
|
||||||
|
`themes.js` only needs the image path.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const THEMES = {
|
||||||
|
'nebula': { bg: 'assets/themes/bg-nebula.webp' },
|
||||||
|
'crescent': { bg: 'assets/themes/bg-crescent.webp' },
|
||||||
|
'event-horizon': { bg: 'assets/themes/bg-event-horizon.webp' },
|
||||||
|
'merchantman': { bg: 'assets/themes/bg-merchantman.webp' },
|
||||||
|
'julia-jin': { bg: 'assets/themes/bg-julia-jin.webp' },
|
||||||
|
'sc-sunset': { bg: 'assets/themes/bg-sc-sunset.webp' },
|
||||||
|
'hellion-hud': { bg: 'assets/themes/bg-hellion-hud.webp' },
|
||||||
|
'hellion-energy': { bg: 'assets/themes/bg-hellion-energy.webp' },
|
||||||
|
'satisfactory': { bg: 'assets/themes/bg-satisfactory.webp' },
|
||||||
|
'avorion': { bg: 'assets/themes/bg-avorion.webp' },
|
||||||
|
'hellion-stealth': { bg: 'assets/themes/bg-scPolaris.webp' }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
To add a new theme, add one line. The key must exactly match the `data-theme`
|
||||||
|
attribute in the CSS block. If they don't match, `applyTheme()` will silently
|
||||||
|
do nothing and no one will know why.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// New theme: key must match [data-theme="your-theme-name"] in main.css
|
||||||
|
'your-theme-name': { bg: 'assets/themes/bg-your-theme.webp' }
|
||||||
|
```
|
||||||
|
|
||||||
|
### How applyTheme() works
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function applyTheme(themeName, skipBgOverride) {
|
||||||
|
const theme = THEMES[themeName];
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
|
// Sets data-theme on <html> — activates the matching CSS variable block
|
||||||
|
document.documentElement.setAttribute('data-theme', themeName);
|
||||||
|
|
||||||
|
// Applies the background image unless a custom background is active
|
||||||
|
if (!skipBgOverride) {
|
||||||
|
document.getElementById('bgLayer').style.backgroundImage = `url('${theme.bg}')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates the active state in the theme picker UI
|
||||||
|
document.querySelectorAll('.theme-card').forEach(card => {
|
||||||
|
card.classList.toggle('active', card.dataset.value === themeName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `skipBgOverride` flag exists for one specific case: when a user has set a custom
|
||||||
|
background image, switching themes should still update the CSS variables and the picker
|
||||||
|
UI, but not wipe their custom image. Pass `true` to skip the background update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a Theme Card to newtab.html
|
||||||
|
|
||||||
|
The theme picker modal lives in `newtab.html` as `#themeOverlay`. Every theme
|
||||||
|
needs a card in the `.theme-grid` — without it the theme exists in CSS and JS
|
||||||
|
but never shows up in the UI.
|
||||||
|
|
||||||
|
Copy this block and add it inside `.theme-grid`, after the last existing card:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="theme-card" data-value="your-theme-name">
|
||||||
|
<img class="theme-card-img" src="assets/themes/bg-your-theme.webp" alt="Your Theme" />
|
||||||
|
<span class="theme-card-label">Your Theme</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things that must match exactly:
|
||||||
|
|
||||||
|
- `data-value` must match the key in `THEMES` in `themes.js`
|
||||||
|
- `data-value` must match the `[data-theme="..."]` attribute in `main.css`
|
||||||
|
- `src` must point to the correct WebP file in `assets/themes/`
|
||||||
|
|
||||||
|
The label shown in the picker can be shorter than the full theme name — "HUD" and
|
||||||
|
"Energy" are good examples of that. Keep it short enough to fit the card.
|
||||||
|
|
||||||
|
The `active` class is toggled by `applyTheme()` automatically, so don't add it
|
||||||
|
manually unless you want that theme to be the default on first load (Nebula currently
|
||||||
|
has it as fallback).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Theme — Checklist
|
||||||
|
|
||||||
|
- [ ] Background image converted to WebP (quality 85)
|
||||||
|
- [ ] Image added to `assets/themes/`
|
||||||
|
- [ ] CSS block added to `src/css/main.css`
|
||||||
|
- [ ] Theme registered in `src/js/themes.js` (one line, key + bg path)
|
||||||
|
- [ ] Theme card added to `.theme-grid` in `newtab.html` (data-value, img src, label)
|
||||||
|
- [ ] Theme added to theme table in `README.md`
|
||||||
|
- [ ] Theme added to theme table in this document
|
||||||
|
- [ ] Image credit added to Bild-Credits table in `README.md`
|
||||||
|
- [ ] `CHANGELOG.md` entry added
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Developed by **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Hellion Dashboard — Architecture
|
||||||
|
|
||||||
|
> This document is intentionally written in English. Full German/English i18n support
|
||||||
|
> is planned for v2.0 — until then, English keeps the docs accessible to anyone
|
||||||
|
> who wants to contribute or fork the project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Hellion Dashboard is a browser extension (NewTab replacement) built with **Vanilla JavaScript ES2020**, **CSS Custom Properties**, and **zero dependencies**. No build step, no framework, no bundler — files are loaded directly via `<script>` tags.
|
||||||
|
|
||||||
|
**Storage:** `chrome.storage.local` with `localStorage` fallback.
|
||||||
|
**Manifest:** V3 across all supported browsers (separate files for Firefox and Opera GX).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
HOM_NewTab_Project/
|
||||||
|
├── newtab.html # Single HTML entry point
|
||||||
|
├── manifest.json # Chrome, Edge, Brave, Vivaldi (MV3)
|
||||||
|
├── manifest.firefox.json # Firefox (MV3)
|
||||||
|
├── manifest.opera.json # Opera, Opera GX (MV3 + workarounds)
|
||||||
|
├── src/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── main.css # All styles, 11 themes, responsive breakpoints
|
||||||
|
│ └── js/
|
||||||
|
│ ├── dialog.js # Custom dialog system (alert, confirm)
|
||||||
|
│ ├── storage.js # Storage abstraction layer
|
||||||
|
│ ├── state.js # Global state, defaults, helpers
|
||||||
|
│ ├── themes.js # Theme definitions & application (11 themes)
|
||||||
|
│ ├── 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)
|
||||||
|
│ ├── onboarding.js # First-run onboarding flow
|
||||||
|
│ ├── 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)
|
||||||
|
│ ├── data.js # JSON export/import (backup & restore)
|
||||||
|
│ └── app.js # Init, clock, global events (entry point)
|
||||||
|
├── assets/
|
||||||
|
│ ├── fonts/ # Local fonts (Rajdhani, Inter, Cinzel)
|
||||||
|
│ ├── icons/ # Extension icons (16-512px)
|
||||||
|
│ └── themes/ # 11 theme background images
|
||||||
|
└── docs/ # You are here
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Responsibilities
|
||||||
|
|
||||||
|
Each module has exactly one responsibility. Communication happens through global references — no import/export, because this is a browser extension without a bundler.
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `dialog.js` | `HellionDialog.alert()` and `HellionDialog.confirm()` — custom styled dialogs that replace native browser popups. Loaded first so every other module can use it. |
|
||||||
|
| `storage.js` | The **only** place that touches `chrome.storage` / `localStorage`. Everything else goes through `Store.get()` / `Store.set()`. |
|
||||||
|
| `state.js` | Global `boards` and `settings` arrays, default values, `uid()`, `escHtml()`, `getFaviconUrl()`. |
|
||||||
|
| `themes.js` | Applies theme CSS variables. 11 themes, each with its own `[data-theme]` block in `main.css`. |
|
||||||
|
| `boards.js` | Renders boards and bookmarks. Event delegation on board containers. |
|
||||||
|
| `drag.js` | Board and bookmark reordering via Pointer Events API. |
|
||||||
|
| `settings.js` | Settings panel UI, toggle handlers, appearance modal, background upload. |
|
||||||
|
| `search.js` | Search bar with engine switching (Google, DuckDuckGo, Bing). |
|
||||||
|
| `onboarding.js` | Multi-slide first-run flow including the gaming starter board opt-in. |
|
||||||
|
| `widgets.js` | Widget manager — creates DOM, handles drag/resize/z-index, provides registry. See [widget-schema.md](widget-schema.md). |
|
||||||
|
| `notes.js` | Notes and checklists as widgets. Multi-instance (max 5). Notebook sidebar. Also handles widget toolbar events. |
|
||||||
|
| `calculator.js` | Calculator widget. Single-instance. Shunting-yard expression parser — no `eval()`. |
|
||||||
|
| `timer.js` | Timer/countdown widget. Single-instance. Presets, Web Audio API alarm, tab-title blink on completion. |
|
||||||
|
| `image-ref.js` | Image reference widget. Multi-instance (max 3). Canvas API WebP conversion, sessionStorage for image data — cleared on browser close. |
|
||||||
|
| `data.js` | JSON export/import with validation. Covers boards, notes, calculator history and timer presets. |
|
||||||
|
| `app.js` | Entry point. Calls `init()` on DOMContentLoaded. Clock, global event binding. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Init Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
DOMContentLoaded
|
||||||
|
→ init()
|
||||||
|
→ Store.get('boards') # Load saved boards
|
||||||
|
→ Store.get('settings') # Load saved settings
|
||||||
|
→ applySettings() # Apply theme, toggles, etc.
|
||||||
|
→ renderBoards() # Render all boards
|
||||||
|
→ startClock() # Start clock/date display
|
||||||
|
→ bindGlobalEvents() # Header buttons, modals
|
||||||
|
→ bindSettingsEvents() # Settings toggles, theme picker
|
||||||
|
→ initSearch() # Search bar
|
||||||
|
→ migrateSticky() # Legacy sticky note migration (v1.5.x → v1.6+)
|
||||||
|
→ Notes.init() # Notes + widget toolbar
|
||||||
|
→ Calculator.init() # Calculator widget
|
||||||
|
→ Timer.init() # Timer widget
|
||||||
|
→ ImageRef.init() # Image reference widget
|
||||||
|
→ initDataButtons() # Export/import buttons
|
||||||
|
→ Onboarding check # First-run onboarding
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Script Load Order
|
||||||
|
|
||||||
|
Scripts are loaded in `newtab.html` in dependency order. A module may only reference modules loaded before it — there is no bundler to handle this automatically.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="src/js/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>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Z-Index Hierarchy
|
||||||
|
|
||||||
|
| Layer | z-index | Elements |
|
||||||
|
|---|---|---|
|
||||||
|
| Background | 0-2 | `#bgLayer`, boards |
|
||||||
|
| Search bar | 90 | `.search-bar-wrapper` |
|
||||||
|
| Widgets + Toolbar | 100+ | `.widget`, `.widget-toolbar` |
|
||||||
|
| Header | 100 | `#header` |
|
||||||
|
| Settings panel | 200 | `#settingsPanel` |
|
||||||
|
| Dialogs / Modals | 300 | `.hellion-dialog-overlay`, modals |
|
||||||
|
| Onboarding | 400 | `#onboardingOverlay` |
|
||||||
|
|
||||||
|
Widgets use an incrementing z-index (`WidgetManager._topZ++`) so the last clicked widget always sits on top.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage Keys
|
||||||
|
|
||||||
|
| Key | Type | Content |
|
||||||
|
|---|---|---|
|
||||||
|
| `boards` | Array | Board objects with bookmarks |
|
||||||
|
| `settings` | Object | User preferences (theme, toggles, etc.) |
|
||||||
|
| `widgetStates` | Object | All widget data — see [widget-schema.md](widget-schema.md) |
|
||||||
|
| `onboardingDone` | Boolean | Whether the first-run onboarding has been completed |
|
||||||
|
| `lastBackupReminder` | Number | Timestamp of the last backup reminder |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
| Browser | Engine | Manifest |
|
||||||
|
|---|---|---|
|
||||||
|
| Chrome | Chromium MV3 | `manifest.json` |
|
||||||
|
| Edge | Chromium MV3 | `manifest.json` |
|
||||||
|
| Brave | Chromium MV3 | `manifest.json` |
|
||||||
|
| Vivaldi | Chromium MV3 | `manifest.json` |
|
||||||
|
| Opera / GX | Chromium MV3 | `manifest.opera.json` |
|
||||||
|
| Firefox | Gecko MV3 | `manifest.firefox.json` |
|
||||||
|
|
||||||
|
Any change that touches manifest fields — version numbers, permissions, content scripts —
|
||||||
|
needs to be applied to all three files. The CI quality check will catch it if they drift out of sync.
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# Hellion Dashboard — Code Patterns & Conventions
|
||||||
|
|
||||||
|
> This document is intentionally written in English. Full German/English i18n support
|
||||||
|
> is planned for v2.0 — until then, English keeps the docs accessible to anyone
|
||||||
|
> who wants to contribute or fork the project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
- **Vanilla JS ES2020** — No frameworks, no TypeScript, no build step
|
||||||
|
- **Zero dependencies** — Everything is built from scratch
|
||||||
|
- **`createElement` only** — Never use `innerHTML` (XSS prevention)
|
||||||
|
- **CSS Custom Properties** — No hardcoded colors, everything through `var(--name)`
|
||||||
|
- **Event delegation** — One listener per container, not per element
|
||||||
|
- **Storage abstraction** — All storage access through `Store.get()` / `Store.set()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern: Storage Abstraction
|
||||||
|
|
||||||
|
**File:** `src/js/storage.js`
|
||||||
|
|
||||||
|
All persistent data goes through the `Store` object. Never access `chrome.storage` or `localStorage` directly — `Store` handles the fallback between the two transparently and provides unified error handling when storage is full.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Reading
|
||||||
|
const boards = await Store.get('boards'); // Returns null if not found
|
||||||
|
const settings = await Store.get('settings');
|
||||||
|
|
||||||
|
// Writing
|
||||||
|
await Store.set('boards', boards);
|
||||||
|
await Store.set('settings', settings);
|
||||||
|
|
||||||
|
// Quota check (chrome.storage only, 10 MB limit)
|
||||||
|
await Store.checkQuota();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern: Event Delegation
|
||||||
|
|
||||||
|
One listener on the container, `closest()` to find the target. Much cleaner than attaching a listener to every single element, and it works automatically for dynamically added content.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// GOOD — one listener, handles all bookmarks
|
||||||
|
container.addEventListener('click', (e) => {
|
||||||
|
const bmItem = e.target.closest('.bm-item');
|
||||||
|
if (!bmItem) return;
|
||||||
|
const id = bmItem.dataset.id;
|
||||||
|
// Handle click
|
||||||
|
});
|
||||||
|
|
||||||
|
// BAD — listener per element
|
||||||
|
bookmarks.forEach(bm => {
|
||||||
|
bm.addEventListener('click', handler); // Don't do this!
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Used in `boards.js` (board/bookmark events), `notes.js` (toolbar) and `calculator.js` (button grid).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern: createElement over innerHTML
|
||||||
|
|
||||||
|
Always build DOM with `document.createElement()`. This is the project's #1 security rule — `innerHTML` with user-provided content is an XSS risk, full stop.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// GOOD
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = bookmark.url;
|
||||||
|
link.textContent = bookmark.title;
|
||||||
|
container.appendChild(link);
|
||||||
|
|
||||||
|
// BAD — XSS risk!
|
||||||
|
container.innerHTML = `<a href="${url}">${title}</a>`;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern: Shared Storage Key
|
||||||
|
|
||||||
|
All widget modules share the `widgetStates` storage key. Every module that writes to it must read first and preserve what's already there — otherwise modules silently overwrite each other's data.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async save() {
|
||||||
|
const data = await Store.get('widgetStates') || {};
|
||||||
|
|
||||||
|
// Write your own data
|
||||||
|
data.yourKey = { /* ... */ };
|
||||||
|
|
||||||
|
// Don't replace the whole object — other modules live here too
|
||||||
|
await Store.set('widgetStates', data);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [widget-schema.md](widget-schema.md) for the full `widgetStates` structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern: Widget Lifecycle Hooks
|
||||||
|
|
||||||
|
Single-instance widgets (Calculator, Timer) need to react when they're closed, minimized, or reopened. They do this by wrapping `WidgetManager` methods in their `init()`.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async init() {
|
||||||
|
const prevClose = WidgetManager.close;
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
WidgetManager.close = function(id) {
|
||||||
|
prevClose.call(WidgetManager, id);
|
||||||
|
if (id === self.WIDGET_ID) {
|
||||||
|
self.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevMinimize = WidgetManager.minimize;
|
||||||
|
WidgetManager.minimize = async function(id) {
|
||||||
|
await prevMinimize.call(WidgetManager, id);
|
||||||
|
if (id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = false;
|
||||||
|
await self.save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple widgets chain these wraps — Calculator wraps first, Timer wraps Calculator's already-wrapped version, and so on. Always call the previous method (`prevClose.call(...)`) or the chain breaks and other widgets stop responding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern: Debounced Save
|
||||||
|
|
||||||
|
For frequent updates like typing in notes or dragging widgets, debouncing avoids hammering storage with a write on every keystroke.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_saveTimer: null,
|
||||||
|
|
||||||
|
_debouncedSave() {
|
||||||
|
clearTimeout(this._saveTimer);
|
||||||
|
this._saveTimer = setTimeout(() => this.save(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use _debouncedSave() instead of save() for frequent events
|
||||||
|
textarea.addEventListener('input', () => {
|
||||||
|
noteData.content = textarea.value;
|
||||||
|
this._debouncedSave();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Used in `notes.js` (text editing) and `image-ref.js` (label editing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern: Theme System
|
||||||
|
|
||||||
|
All themes use CSS Custom Properties in `[data-theme="name"]` blocks in `main.css`. There are currently 11 themes.
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme="nebula"] {
|
||||||
|
--bg-primary: #0a0e17;
|
||||||
|
--bg-board: rgba(15, 20, 35, 0.65);
|
||||||
|
--text-primary: #e0e6f0;
|
||||||
|
--accent: #7db3ff;
|
||||||
|
--border: rgba(125, 179, 255, 0.12);
|
||||||
|
/* ... more variables */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Never hardcode colors in JS. Let CSS handle it.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// GOOD
|
||||||
|
element.classList.add('active');
|
||||||
|
|
||||||
|
// BAD — breaks every theme that isn't Nebula
|
||||||
|
element.style.color = '#7db3ff';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern: Onboarding Slides
|
||||||
|
|
||||||
|
The onboarding system in `onboarding.js` is data-driven. Each slide is a plain object — add a new slide by adding an object to the `slides` array, the `_render()` method handles the rest.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
hero: '🎮', // Large emoji/icon
|
||||||
|
title: 'Slide Title',
|
||||||
|
text: 'Optional description',
|
||||||
|
features: ['Item 1', ...], // Optional bullet list
|
||||||
|
showThemes: true, // Optional theme grid
|
||||||
|
interactive: 'gaming-board' // Optional custom buttons
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern: Dialog System
|
||||||
|
|
||||||
|
Custom dialogs replace native `alert()` and `confirm()` everywhere in the project.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Informational
|
||||||
|
await HellionDialog.alert('Message text', {
|
||||||
|
type: 'info', // 'info', 'success', 'warning', 'danger'
|
||||||
|
title: 'Title'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Yes/no
|
||||||
|
const ok = await HellionDialog.confirm('Are you sure?', {
|
||||||
|
type: 'danger',
|
||||||
|
title: 'Delete',
|
||||||
|
confirmText: 'Delete',
|
||||||
|
cancelText: 'Cancel'
|
||||||
|
});
|
||||||
|
if (ok) { /* user confirmed */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern: Pointer Events for Drag
|
||||||
|
|
||||||
|
Widget dragging and board reordering use the Pointer Events API instead of mouse events. The reason: Pointer Events work with both mouse and touch, and `setPointerCapture` keeps the events flowing even if the cursor leaves the element mid-drag.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
element.addEventListener('pointerdown', (e) => {
|
||||||
|
element.setPointerCapture(e.pointerId);
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
// Update position
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
element.releasePointerCapture(e.pointerId);
|
||||||
|
element.removeEventListener('pointermove', onMove);
|
||||||
|
element.removeEventListener('pointerup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.addEventListener('pointermove', onMove);
|
||||||
|
element.addEventListener('pointerup', onUp);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern: Canvas API Image Processing
|
||||||
|
|
||||||
|
The image reference widget converts uploaded images to WebP locally in the browser — no external service, no upload, nothing leaves the device.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_processFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const webpUrl = canvas.toDataURL('image/webp', 0.85);
|
||||||
|
URL.revokeObjectURL(objectUrl); // Always free the object URL
|
||||||
|
resolve(webpUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
reject(new Error('Image could not be loaded'));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = objectUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Always call `URL.revokeObjectURL()` after the image has loaded — skipping it leaks memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coding Rules Summary
|
||||||
|
|
||||||
|
| Rule | Rationale |
|
||||||
|
|---|---|
|
||||||
|
| `createElement` only, never `innerHTML` | XSS prevention |
|
||||||
|
| All storage through `Store` | Browser compatibility + unified error handling |
|
||||||
|
| CSS variables, no hardcoded colors | Theme support across all 11 themes |
|
||||||
|
| Event delegation | Performance, works with dynamic content |
|
||||||
|
| `const`/`let`, never `var` | Block scoping |
|
||||||
|
| No external dependencies | Extension simplicity |
|
||||||
|
| No build step | Direct development, no toolchain to break |
|
||||||
|
| JSDoc comments on public functions | Documentation for contributors |
|
||||||
|
| URL validation before `href` | Security |
|
||||||
|
| Error handling on storage operations | Graceful failure |
|
||||||
|
| `URL.revokeObjectURL()` after Canvas ops | Memory management |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manifest Synchronization
|
||||||
|
|
||||||
|
Three manifest files must always stay in sync:
|
||||||
|
|
||||||
|
- `manifest.json` — Chrome, Edge, Brave, Vivaldi
|
||||||
|
- `manifest.firefox.json` — Firefox
|
||||||
|
- `manifest.opera.json` — Opera, Opera GX
|
||||||
|
|
||||||
|
Version numbers, permissions and content script entries need to be updated in all three. The CI quality check will catch drift, but it's cleaner not to let it get there in the first place.
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
# Hellion Dashboard — Widget Schema
|
||||||
|
|
||||||
|
> This document is intentionally written in English. Full German/English i18n support
|
||||||
|
> is planned for v2.0 — until then, English keeps the docs accessible to anyone
|
||||||
|
> who wants to contribute or fork the project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The widget system provides draggable, resizable floating panels managed by `WidgetManager` (`src/js/widgets.js`). Each widget type has its own module that handles content rendering and state management — `WidgetManager` only knows about DOM and position, never about content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Types
|
||||||
|
|
||||||
|
| Type | Module | Instance | Max | Storage |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `note` | `notes.js` | Multi | 5 | Persistent (`widgetStates.notes`) |
|
||||||
|
| `calculator` | `calculator.js` | Single | 1 | Persistent (`widgetStates.calculator`) |
|
||||||
|
| `timer` | `timer.js` | Single | 1 | Persistent (`widgetStates.timer`) |
|
||||||
|
| `image` | `image-ref.js` | Multi | 3 | Meta: persistent, Image data: sessionStorage |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WidgetManager API
|
||||||
|
|
||||||
|
### `create(type, config) → string`
|
||||||
|
|
||||||
|
Creates a widget and appends it to the DOM. Returns the widget ID.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const id = WidgetManager.create('note', {
|
||||||
|
id: 'note_abc123', // Optional, auto-generated if omitted
|
||||||
|
title: 'My Note', // Default: 'Note'
|
||||||
|
x: 120, // Left position in px
|
||||||
|
y: 80, // Top position in px
|
||||||
|
width: 280, // Width in px (min: 200)
|
||||||
|
height: 220, // Height in px (min: 150)
|
||||||
|
open: true // Visible state (default: true)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### `getBody(id) → HTMLElement | null`
|
||||||
|
|
||||||
|
Returns the `.widget-body` element. This is where your module renders its content.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const body = WidgetManager.getBody('widget_calculator');
|
||||||
|
if (body) Calculator.renderBody(body);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `getState(id) → Object | null`
|
||||||
|
|
||||||
|
Returns the current widget state — position, size, open status.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const state = WidgetManager.getState('widget_timer');
|
||||||
|
// → { id, type, title, x, y, width, height, open }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `close(id)`
|
||||||
|
|
||||||
|
Permanently removes a widget from the DOM and registry. No undo.
|
||||||
|
|
||||||
|
### `minimize(id)`
|
||||||
|
|
||||||
|
Hides a widget with animation. The widget stays in the registry with `open: false` so it can be restored.
|
||||||
|
|
||||||
|
### `openWidget(id)`
|
||||||
|
|
||||||
|
Restores a minimized widget with animation.
|
||||||
|
|
||||||
|
### `bringToFront(id)`
|
||||||
|
|
||||||
|
Increments z-index so the widget sits above everything else. Called automatically on `pointerdown`.
|
||||||
|
|
||||||
|
### `save() → Array`
|
||||||
|
|
||||||
|
Returns an array of all `type: 'note'` widget states. Used by `Notes.save()` to merge position/size data with note content.
|
||||||
|
|
||||||
|
### `restore(renderCallback)`
|
||||||
|
|
||||||
|
Loads widget states from storage and recreates all note widgets. Single-instance widgets (Calculator, Timer) restore themselves in their own `init()` — `restore()` only handles notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Storage Key: `widgetStates`
|
||||||
|
|
||||||
|
All widget modules share a single storage key. Every module's `save()` must read first and preserve whatever it doesn't own — otherwise modules silently wipe each other's data on every save.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Full widgetStates structure
|
||||||
|
{
|
||||||
|
notes: [
|
||||||
|
{
|
||||||
|
id: 'note_abc123',
|
||||||
|
title: 'My Note',
|
||||||
|
content: 'Hello world',
|
||||||
|
template: 'text', // 'text' or 'checklist'
|
||||||
|
x: 120, y: 80,
|
||||||
|
width: 280, height: 220,
|
||||||
|
open: true,
|
||||||
|
checklistItems: [], // Only used by checklist template
|
||||||
|
checkedItems: [] // Checked item IDs
|
||||||
|
}
|
||||||
|
],
|
||||||
|
calculator: {
|
||||||
|
x: 400, y: 120,
|
||||||
|
width: 280, height: 400,
|
||||||
|
open: false,
|
||||||
|
history: [
|
||||||
|
{ expr: '2 + 3', result: '5' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
timer: {
|
||||||
|
x: 600, y: 80,
|
||||||
|
width: 260, height: 360,
|
||||||
|
open: false,
|
||||||
|
muted: false,
|
||||||
|
presets: [
|
||||||
|
{ name: 'Forschung', seconds: 2700 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
imageRef: {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
id: 'image_0',
|
||||||
|
label: 'Bauplan',
|
||||||
|
x: 200, y: 120,
|
||||||
|
width: 320, height: 280,
|
||||||
|
open: true
|
||||||
|
// Image data is NOT stored here — sessionStorage only
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Save Pattern
|
||||||
|
|
||||||
|
Every module that touches `widgetStates` must follow this pattern:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// From notes.js — same pattern applies to every widget module
|
||||||
|
async save() {
|
||||||
|
const existing = await Store.get(this.STORAGE_KEY);
|
||||||
|
const saveData = { notes: mergedNotes };
|
||||||
|
|
||||||
|
// Preserve everything we don't own
|
||||||
|
if (existing && existing.calculator) saveData.calculator = existing.calculator;
|
||||||
|
if (existing && existing.timer) saveData.timer = existing.timer;
|
||||||
|
if (existing && existing.imageRef) saveData.imageRef = existing.imageRef;
|
||||||
|
|
||||||
|
await Store.set(this.STORAGE_KEY, saveData);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating a New Widget Type
|
||||||
|
|
||||||
|
### Step 1: Single or Multi-Instance?
|
||||||
|
|
||||||
|
**Single-instance** (Calculator, Timer style): one widget, fixed ID, `toggle()` opens and closes it.
|
||||||
|
**Multi-instance** (Notes, ImageRef style): multiple widgets, dynamic IDs, `create()` adds new ones.
|
||||||
|
|
||||||
|
### Step 2: Create the Module
|
||||||
|
|
||||||
|
Here's a minimal single-instance widget template. Follow the same structure — the lifecycle hooks especially are easy to get wrong.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const YourWidget = {
|
||||||
|
WIDGET_ID: 'widget_yourwidget',
|
||||||
|
STORAGE_KEY: 'widgetStates',
|
||||||
|
_isOpen: false,
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
|
if (data && data.yourWidget) {
|
||||||
|
// Restore your state
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
const data = await Store.get(this.STORAGE_KEY) || {};
|
||||||
|
if (data.notes === undefined) data.notes = [];
|
||||||
|
|
||||||
|
const widgetState = WidgetManager.getState(this.WIDGET_ID);
|
||||||
|
data.yourWidget = {
|
||||||
|
x: widgetState ? widgetState.x : 400,
|
||||||
|
y: widgetState ? widgetState.y : 120,
|
||||||
|
width: widgetState ? widgetState.width : 280,
|
||||||
|
height: widgetState ? widgetState.height : 300,
|
||||||
|
open: this._isOpen,
|
||||||
|
// ... your custom data
|
||||||
|
};
|
||||||
|
|
||||||
|
await Store.set(this.STORAGE_KEY, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
if (this._isOpen) {
|
||||||
|
WidgetManager.bringToFront(this.WIDGET_ID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
|
const saved = (data && data.yourWidget) ? data.yourWidget : {};
|
||||||
|
|
||||||
|
WidgetManager.create('yourwidget', {
|
||||||
|
id: this.WIDGET_ID,
|
||||||
|
title: 'Your Widget',
|
||||||
|
x: saved.x || 400,
|
||||||
|
y: saved.y || 120,
|
||||||
|
width: saved.width || 280,
|
||||||
|
height: saved.height || 300,
|
||||||
|
open: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = WidgetManager.getBody(this.WIDGET_ID);
|
||||||
|
if (body) this.renderBody(body);
|
||||||
|
|
||||||
|
this._isOpen = true;
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggle() {
|
||||||
|
if (this._isOpen) {
|
||||||
|
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||||
|
if (entry && entry.state.open) {
|
||||||
|
await WidgetManager.minimize(this.WIDGET_ID);
|
||||||
|
this._isOpen = false;
|
||||||
|
await this.save();
|
||||||
|
} else if (entry) {
|
||||||
|
await WidgetManager.openWidget(this.WIDGET_ID);
|
||||||
|
this._isOpen = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderBody(bodyEl) {
|
||||||
|
bodyEl.textContent = '';
|
||||||
|
// Build your UI with createElement — never innerHTML!
|
||||||
|
},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.load();
|
||||||
|
|
||||||
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
|
if (data && data.yourWidget && data.yourWidget.open) {
|
||||||
|
await this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle hooks — always call the previous method first
|
||||||
|
// or you'll break every widget that wrapped before yours
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
const prevClose = WidgetManager.close;
|
||||||
|
WidgetManager.close = function(id) {
|
||||||
|
prevClose.call(WidgetManager, id);
|
||||||
|
if (id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = false;
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevMinimize = WidgetManager.minimize;
|
||||||
|
WidgetManager.minimize = async function(id) {
|
||||||
|
await prevMinimize.call(WidgetManager, id);
|
||||||
|
if (id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = false;
|
||||||
|
await self.save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevOpen = WidgetManager.openWidget;
|
||||||
|
WidgetManager.openWidget = async function(id) {
|
||||||
|
await prevOpen.call(WidgetManager, id);
|
||||||
|
if (id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = true;
|
||||||
|
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||||
|
if (body && body.children.length === 0) {
|
||||||
|
self.renderBody(body);
|
||||||
|
}
|
||||||
|
await self.save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Integration Checklist
|
||||||
|
|
||||||
|
1. `newtab.html` — Add `<script>` tag after `widgets.js` and before `data.js`
|
||||||
|
2. `newtab.html` — Add toolbar button: `<button class="widget-toolbar-btn" data-action="your-action">`
|
||||||
|
3. `notes.js` — Add handler in `initToolbar()`: `else if (action === 'your-action') { YourWidget.toggle(); }`
|
||||||
|
4. `notes.js` — Preserve your key in `save()`: `if (existing && existing.yourWidget) saveData.yourWidget = existing.yourWidget;`
|
||||||
|
5. `app.js` — Add `await YourWidget.init();` to the init sequence
|
||||||
|
6. `main.css` — Add widget-specific styles
|
||||||
|
7. `data.js` — Add export/import logic if your data should survive a JSON backup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget DOM Structure
|
||||||
|
|
||||||
|
Every widget created by `WidgetManager.create()` has this structure. Your module renders into `.widget-body` via `renderBody()` — never touch the header or resize handle.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="widget" data-widget-id="widget_abc123"
|
||||||
|
style="left: 120px; top: 80px; width: 280px; height: 220px;">
|
||||||
|
<div class="widget-header"> <!-- Drag handle -->
|
||||||
|
<span class="widget-title">Title</span> <!-- Double-click to edit, max 20 chars -->
|
||||||
|
<div class="widget-actions">
|
||||||
|
<button class="widget-btn widget-minimize">─</button>
|
||||||
|
<button class="widget-btn widget-close">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="widget-body">
|
||||||
|
<!-- Your content goes here via renderBody() -->
|
||||||
|
</div>
|
||||||
|
<div class="widget-resize-handle"></div> <!-- Bottom-right, visible on hover -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
@@ -1,26 +1,41 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 3,
|
||||||
"name": "Hellion NewTab",
|
"name": "Hellion NewTab",
|
||||||
"version": "1.2.0",
|
"version": "1.11.1",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Hellion NewTab",
|
"name": "Hellion NewTab",
|
||||||
"version": "1.2.0",
|
"version": "1.11.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Hellion Dashboard (GX Native)",
|
||||||
|
"version": "1.11.1",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,317 @@
|
|||||||
</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 & 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" id="browserImportRow">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">Browser-Lesezeichen</span>
|
||||||
|
<span class="setting-desc">Lesezeichen direkt aus dem Browser importieren</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-small" id="btnBrowserImport">Import</button>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">Onboarding wiederholen</span>
|
||||||
|
<span class="setting-desc">Willkommens-Tour erneut anzeigen</span>
|
||||||
|
</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.11.1 · 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.webp" alt="Nebula" />
|
||||||
|
<span class="theme-card-label">Nebula</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card" data-value="crescent">
|
||||||
|
<img class="theme-card-img" src="assets/themes/bg-crescent.webp" alt="Crescent" />
|
||||||
|
<span class="theme-card-label">Crescent</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card" data-value="event-horizon">
|
||||||
|
<img class="theme-card-img" src="assets/themes/bg-event-horizon.webp" alt="Event Horizon" />
|
||||||
|
<span class="theme-card-label">Event Horizon</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card" data-value="merchantman">
|
||||||
|
<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.webp" alt="Julia & Jin" />
|
||||||
|
<span class="theme-card-label">Julia & 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.webp" alt="SC Sunset" />
|
||||||
|
<span class="theme-card-label">SC Sunset</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card" data-value="hellion-hud">
|
||||||
|
<img class="theme-card-img" src="assets/themes/bg-hellion-hud.webp" alt="Hellion HUD" />
|
||||||
|
<span class="theme-card-label">HUD</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card" data-value="hellion-energy">
|
||||||
|
<img class="theme-card-img" src="assets/themes/bg-hellion-energy.webp" alt="Hellion Energy" />
|
||||||
|
<span class="theme-card-label">Energy</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card" data-value="satisfactory">
|
||||||
|
<img class="theme-card-img" src="assets/themes/bg-satisfactory.webp" alt="Satisfactory" />
|
||||||
|
<span class="theme-card-label">Satisfactory</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card" data-value="avorion">
|
||||||
|
<img class="theme-card-img" src="assets/themes/bg-avorion.webp" alt="Avorion" />
|
||||||
|
<span class="theme-card-label">Avorion</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card" data-value="hellion-stealth">
|
||||||
|
<img class="theme-card-img" src="assets/themes/bg-scPolaris.webp" alt="Hellion Stealth" />
|
||||||
|
<span class="theme-card-label">Stealth</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="theme-modal-section">
|
||||||
|
<h3 class="settings-section-title">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 +377,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 +438,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 +463,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 +481,15 @@
|
|||||||
<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/bookmark-import.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>
|
||||||
|
|||||||
@@ -16,9 +16,106 @@ async function init() {
|
|||||||
bindGlobalEvents();
|
bindGlobalEvents();
|
||||||
bindSettingsEvents();
|
bindSettingsEvents();
|
||||||
initSearch();
|
initSearch();
|
||||||
initStickyNote();
|
await migrateSticky();
|
||||||
|
await Notes.init();
|
||||||
|
await Calculator.init();
|
||||||
|
await Timer.init();
|
||||||
|
await ImageRef.init();
|
||||||
|
BrowserBookmarkImport.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.11.1', 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 +147,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 +189,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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — bookmark-import.js
|
||||||
|
Direkt-Import von Browser-Lesezeichen
|
||||||
|
via chrome.bookmarks.getTree() / browser.bookmarks.getTree()
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
const BrowserBookmarkImport = {
|
||||||
|
|
||||||
|
/** Initialisiert den Import-Button */
|
||||||
|
init() {
|
||||||
|
const btn = document.getElementById('btnBrowserImport');
|
||||||
|
const row = document.getElementById('browserImportRow');
|
||||||
|
if (!btn || !row) return;
|
||||||
|
|
||||||
|
// API-Verfuegbarkeit pruefen (nicht vorhanden im normalen Browser-Tab)
|
||||||
|
const api = this._getApi();
|
||||||
|
if (!api) {
|
||||||
|
row.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => this._openFolderModal());
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Bookmarks-API zurueck (Chrome oder Firefox)
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
_getApi() {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.bookmarks) return chrome.bookmarks;
|
||||||
|
if (typeof browser !== 'undefined' && browser.bookmarks) return browser.bookmarks;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Oeffnet das Ordner-Auswahl Modal */
|
||||||
|
async _openFolderModal() {
|
||||||
|
const api = this._getApi();
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
let tree;
|
||||||
|
try {
|
||||||
|
tree = await api.getTree();
|
||||||
|
} catch (err) {
|
||||||
|
await HellionDialog.alert(
|
||||||
|
'Zugriff auf Browser-Lesezeichen nicht möglich. Stelle sicher, dass die Extension die nötigen Berechtigungen hat.',
|
||||||
|
{ type: 'warning', title: 'Lesezeichen-Import' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = this._extractFolders(tree[0]);
|
||||||
|
if (folders.length === 0) {
|
||||||
|
await HellionDialog.alert(
|
||||||
|
'Keine Lesezeichen-Ordner gefunden.',
|
||||||
|
{ type: 'warning', title: 'Lesezeichen-Import' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._renderModal(folders);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert alle Ordner rekursiv aus dem Bookmark-Baum
|
||||||
|
* @param {object} node - Bookmark-Tree Node
|
||||||
|
* @param {number} depth - Einrueckungstiefe
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
_extractFolders(node, depth) {
|
||||||
|
if (depth === undefined) depth = 0;
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
if (!node.children) return result;
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.children) {
|
||||||
|
const bookmarkCount = child.children.filter(function(c) { return c.url; }).length;
|
||||||
|
const subfolderCount = child.children.filter(function(c) { return c.children; }).length;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: child.id,
|
||||||
|
title: child.title || 'Unbenannt',
|
||||||
|
depth: depth,
|
||||||
|
bookmarkCount: bookmarkCount,
|
||||||
|
subfolderCount: subfolderCount,
|
||||||
|
node: child
|
||||||
|
});
|
||||||
|
|
||||||
|
const subFolders = this._extractFolders(child, depth + 1);
|
||||||
|
for (const sf of subFolders) {
|
||||||
|
result.push(sf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert das Ordner-Auswahl Modal
|
||||||
|
* @param {Array} folders - Liste der Ordner
|
||||||
|
*/
|
||||||
|
_renderModal(folders) {
|
||||||
|
// Overlay
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'bm-import-overlay';
|
||||||
|
overlay.id = 'bmImportOverlay';
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'bm-import-modal';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'bm-import-header';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.textContent = 'Browser-Lesezeichen importieren';
|
||||||
|
header.appendChild(title);
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'bm-import-close';
|
||||||
|
closeBtn.textContent = '\u00D7';
|
||||||
|
closeBtn.addEventListener('click', () => this._closeModal());
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
|
||||||
|
modal.appendChild(header);
|
||||||
|
|
||||||
|
// Info
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'bm-import-info';
|
||||||
|
info.textContent = 'Wähle die Ordner aus, die als Boards importiert werden sollen. Jeder Ordner wird ein eigenes Board.';
|
||||||
|
modal.appendChild(info);
|
||||||
|
|
||||||
|
// Ordner-Liste
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'bm-import-list';
|
||||||
|
|
||||||
|
for (const folder of folders) {
|
||||||
|
const row = document.createElement('label');
|
||||||
|
row.className = 'bm-import-folder';
|
||||||
|
row.style.paddingLeft = (12 + folder.depth * 20) + 'px';
|
||||||
|
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.className = 'bm-import-checkbox';
|
||||||
|
checkbox.dataset.folderId = folder.id;
|
||||||
|
row.appendChild(checkbox);
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'bm-import-folder-name';
|
||||||
|
label.textContent = folder.title;
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'bm-import-folder-meta';
|
||||||
|
const parts = [];
|
||||||
|
if (folder.bookmarkCount > 0) {
|
||||||
|
parts.push(folder.bookmarkCount + ' Link' + (folder.bookmarkCount !== 1 ? 's' : ''));
|
||||||
|
}
|
||||||
|
if (folder.subfolderCount > 0) {
|
||||||
|
parts.push(folder.subfolderCount + ' Ordner');
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
parts.push('leer');
|
||||||
|
}
|
||||||
|
meta.textContent = parts.join(', ');
|
||||||
|
row.appendChild(meta);
|
||||||
|
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.appendChild(list);
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
const footer = document.createElement('div');
|
||||||
|
footer.className = 'bm-import-footer';
|
||||||
|
|
||||||
|
const selectAll = document.createElement('button');
|
||||||
|
selectAll.className = 'btn-secondary';
|
||||||
|
selectAll.textContent = 'Alle auswählen';
|
||||||
|
selectAll.addEventListener('click', () => {
|
||||||
|
const boxes = list.querySelectorAll('.bm-import-checkbox');
|
||||||
|
const allChecked = Array.from(boxes).every(function(cb) { return cb.checked; });
|
||||||
|
boxes.forEach(function(cb) { cb.checked = !allChecked; });
|
||||||
|
selectAll.textContent = allChecked ? 'Alle auswählen' : 'Alle abwählen';
|
||||||
|
});
|
||||||
|
footer.appendChild(selectAll);
|
||||||
|
|
||||||
|
const importBtn = document.createElement('button');
|
||||||
|
importBtn.className = 'btn-primary';
|
||||||
|
importBtn.textContent = 'Importieren';
|
||||||
|
importBtn.addEventListener('click', () => this._importSelected(folders));
|
||||||
|
footer.appendChild(importBtn);
|
||||||
|
|
||||||
|
modal.appendChild(footer);
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Animation
|
||||||
|
requestAnimationFrame(() => overlay.classList.add('active'));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Schliesst das Modal */
|
||||||
|
_closeModal() {
|
||||||
|
const overlay = document.getElementById('bmImportOverlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
setTimeout(() => overlay.remove(), 250);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importiert die ausgewaehlten Ordner als Boards
|
||||||
|
* @param {Array} folders - Alle Ordner
|
||||||
|
*/
|
||||||
|
async _importSelected(folders) {
|
||||||
|
const checkboxes = document.querySelectorAll('.bm-import-checkbox:checked');
|
||||||
|
if (checkboxes.length === 0) {
|
||||||
|
await HellionDialog.alert(
|
||||||
|
'Bitte wähle mindestens einen Ordner aus.',
|
||||||
|
{ type: 'warning', title: 'Lesezeichen-Import' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestehende URLs sammeln fuer Duplikat-Erkennung
|
||||||
|
const existingUrls = new Set();
|
||||||
|
for (const board of boards) {
|
||||||
|
for (const bm of board.bookmarks) {
|
||||||
|
existingUrls.add(bm.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = new Set();
|
||||||
|
checkboxes.forEach(function(cb) { selectedIds.add(cb.dataset.folderId); });
|
||||||
|
|
||||||
|
let totalImported = 0;
|
||||||
|
let totalSkipped = 0;
|
||||||
|
let boardsCreated = 0;
|
||||||
|
|
||||||
|
for (const folder of folders) {
|
||||||
|
if (!selectedIds.has(folder.id)) continue;
|
||||||
|
|
||||||
|
const bookmarks = [];
|
||||||
|
for (const child of folder.node.children) {
|
||||||
|
if (!child.url) continue;
|
||||||
|
|
||||||
|
// Nur http/https URLs
|
||||||
|
try {
|
||||||
|
const parsed = new URL(child.url);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplikat-Check
|
||||||
|
if (existingUrls.has(child.url)) {
|
||||||
|
totalSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmarks.push({
|
||||||
|
id: uid(),
|
||||||
|
title: child.title || child.url,
|
||||||
|
url: child.url,
|
||||||
|
desc: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
existingUrls.add(child.url);
|
||||||
|
totalImported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bookmarks.length === 0) continue;
|
||||||
|
|
||||||
|
boards.push({
|
||||||
|
id: uid(),
|
||||||
|
title: folder.title,
|
||||||
|
bookmarks: bookmarks,
|
||||||
|
blurred: false
|
||||||
|
});
|
||||||
|
boardsCreated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boardsCreated > 0) {
|
||||||
|
await saveBoards();
|
||||||
|
renderBoards();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._closeModal();
|
||||||
|
|
||||||
|
// Ergebnis-Dialog
|
||||||
|
const lines = [];
|
||||||
|
lines.push(boardsCreated + ' Board' + (boardsCreated !== 1 ? 's' : '') + ' erstellt');
|
||||||
|
lines.push(totalImported + ' Lesezeichen importiert');
|
||||||
|
if (totalSkipped > 0) {
|
||||||
|
lines.push(totalSkipped + ' Duplikat' + (totalSkipped !== 1 ? 'e' : '') + ' übersprungen');
|
||||||
|
}
|
||||||
|
|
||||||
|
await HellionDialog.alert(
|
||||||
|
lines.join('\n'),
|
||||||
|
{ type: 'success', title: 'Import abgeschlossen' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.11.1',
|
||||||
|
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 = '';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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: '11 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', 'Satisfactory', 'Avorion', 'Hellion Stealth'];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# ⬡ Opera GX — New-Tab Workaround
|
||||||
|
|
||||||
|
Opera GX ist der einzige Browser in diesem Projekt der sich aktiv dagegen wehrt,
|
||||||
|
eine eigene New-Tab-Seite zu nutzen. Während Chrome, Edge, Firefox und selbst Vivaldi
|
||||||
|
einfach `chrome_url_overrides` respektieren, priorisiert Opera GX seine eigene
|
||||||
|
Speed Dial Seite und ignoriert den Standard-Override für entpackte Erweiterungen.
|
||||||
|
|
||||||
|
Das Ergebnis: vier Stunden Debugging, zwei Workaround-Skripte und ein Reddit-Thread
|
||||||
|
der tatsächlich geholfen hat. Hier ist was dabei rausgekommen ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warum zwei extra Skripte?
|
||||||
|
|
||||||
|
| Browser | New-Tab Override | Zusatzaufwand |
|
||||||
|
|---|---|---|
|
||||||
|
| Chrome / Edge / Brave / Vivaldi | `chrome_url_overrides` | Keiner |
|
||||||
|
| Firefox | `chrome_url_overrides` (MV3) | Eigenes Manifest |
|
||||||
|
| Opera / Opera GX | Blockiert durch Speed Dial | Dieser Ordner hier |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was passiert hier?
|
||||||
|
|
||||||
|
### `background.js` — Tab-Management
|
||||||
|
|
||||||
|
Überwacht Tab-Aktivitäten im Hintergrund und greift ein bevor Opera seine Startseite laden kann.
|
||||||
|
|
||||||
|
- Erkennt `opera://startpage/` und `chrome://startpage/`
|
||||||
|
- Leitet per `chrome.tabs.update` auf `newtab.html` um
|
||||||
|
- Prüft zusätzlich bei `onActivated`, weil Opera manche Tabs im Hintergrund lädt
|
||||||
|
und der erste Redirect dann nicht greift
|
||||||
|
|
||||||
|
### `redirect.js` — In-Page Redirect
|
||||||
|
|
||||||
|
Manche Opera-Systemprozesse sind so weit isoliert dass ein externer Eingriff
|
||||||
|
nicht zuverlässig ankommt. Also nochmal von innen.
|
||||||
|
|
||||||
|
- Wird als Content Script direkt in Opera-Startseiten-Bereiche injiziert
|
||||||
|
- Löst den Redirect bei `document_start` aus, bevor die Speed Dial Seite
|
||||||
|
überhaupt anfangen kann sich aufzubauen
|
||||||
|
|
||||||
|
Ja, es braucht wirklich beide Skripte. Opera GX hat das so entschieden.
|
||||||
|
|
||||||
|
Das Gute daran: die GitHub Actions kümmern sich darum dass jeder Browser nur bekommt
|
||||||
|
was er braucht. Das Opera-ZIP enthält die Workaround-Skripte, das Chrome-ZIP nicht.
|
||||||
|
Wer sich das manuell zusammensuchen müsste wäre vermutlich genauso genervt wie ich
|
||||||
|
beim Debuggen war.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenschutz
|
||||||
|
|
||||||
|
Kein Tracking, keine Speicherung, keine externen Requests.
|
||||||
|
Nur Standard-Browser-APIs, `chrome.tabs`, um zurückzubekommen was eigentlich
|
||||||
|
standardmäßig funktionieren sollte.
|
||||||
|
|
||||||
|
**100% lokal. 0% Analytics. Wie im gesamten Hellion NewTab Projekt.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Entwickelt von **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
(function() {
|
||||||
|
const dashboardUrl = chrome.runtime.getURL("newtab.html");
|
||||||
|
if (window.location.href !== dashboardUrl) {
|
||||||
|
window.location.href = dashboardUrl;
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,17 @@
|
|||||||
============================================= */
|
============================================= */
|
||||||
|
|
||||||
const THEMES = {
|
const THEMES = {
|
||||||
'nebula': { bg: 'assets/themes/bg-nebula.jpg' },
|
'nebula': { bg: 'assets/themes/bg-nebula.webp' },
|
||||||
'crescent': { bg: 'assets/themes/bg-crescent.jpg' },
|
'crescent': { bg: 'assets/themes/bg-crescent.webp' },
|
||||||
'event-horizon': { bg: 'assets/themes/bg-event-horizon.jpg' },
|
'event-horizon': { bg: 'assets/themes/bg-event-horizon.webp' },
|
||||||
'merchantman': { bg: 'assets/themes/bg-merchantman.webp' },
|
'merchantman': { bg: 'assets/themes/bg-merchantman.webp' },
|
||||||
'julia-jin': { bg: 'assets/themes/bg-julia-jin.png' },
|
'julia-jin': { bg: 'assets/themes/bg-julia-jin.webp' },
|
||||||
'sc-sunset': { bg: 'assets/themes/bg-sc-sunset.jpg' },
|
'sc-sunset': { bg: 'assets/themes/bg-sc-sunset.webp' },
|
||||||
'hellion-hud': { bg: 'assets/themes/bg-hellion-hud.png' },
|
'hellion-hud': { bg: 'assets/themes/bg-hellion-hud.webp' },
|
||||||
'hellion-energy': { bg: 'assets/themes/bg-hellion-energy.jpg' }
|
'hellion-energy': { bg: 'assets/themes/bg-hellion-energy.webp' },
|
||||||
|
'satisfactory': { bg: 'assets/themes/bg-satisfactory.webp' },
|
||||||
|
'avorion': { bg: 'assets/themes/bg-avorion.webp' },
|
||||||
|
'hellion-stealth': { bg: 'assets/themes/bg-scPolaris.webp' }
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyTheme(themeName, skipBgOverride) {
|
function applyTheme(themeName, skipBgOverride) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
};
|
||||||