Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f08d5d7563 | |||
| b55bb7ac34 | |||
| 37e45a2041 |
@@ -1,13 +0,0 @@
|
||||
name: Security
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
uses: JonKazama-Hellion/security-workflows/.gitea/workflows/security-scan.yml@main
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Hellion NewTab — Code Owners
|
||||
# Alle Änderungen müssen von @JonKazama-Hellion approved werden
|
||||
|
||||
* @JonKazama-Hellion
|
||||
@@ -1,7 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,4 +1,4 @@
|
||||
# Release — creates ZIP packages for Chrome, Firefox and Opera on new tag
|
||||
# Release — erstellt ZIP-Pakete für Chrome, Firefox und Opera bei neuem Tag
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -17,42 +17,42 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
- name: Version aus Tag extrahieren
|
||||
id: version
|
||||
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Chrome/Edge ZIP (Manifest V3)
|
||||
- name: Chrome/Edge ZIP erstellen (Manifest V3)
|
||||
run: |
|
||||
mkdir -p dist
|
||||
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \
|
||||
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
|
||||
manifest.json newtab.html src/js/*.js src/css/ assets/ \
|
||||
-x "*.git*" "dist/*" ".github/*" "src/js/opera/*"
|
||||
|
||||
- name: Create Firefox ZIP (Manifest V3)
|
||||
- name: Firefox ZIP erstellen (Manifest V3)
|
||||
run: |
|
||||
cp manifest.json manifest.chrome-backup.json
|
||||
cp manifest.firefox.json manifest.json
|
||||
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \
|
||||
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
|
||||
manifest.json newtab.html src/js/*.js src/css/ assets/ \
|
||||
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*"
|
||||
mv manifest.chrome-backup.json manifest.json
|
||||
|
||||
- name: Create Opera/Opera GX ZIP (Manifest V3 + workaround)
|
||||
- name: Opera/Opera GX ZIP erstellen (Manifest V3 + Workaround)
|
||||
run: |
|
||||
cp manifest.json manifest.chrome-backup.json
|
||||
cp manifest.opera.json manifest.json
|
||||
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \
|
||||
manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ _locales/ \
|
||||
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
|
||||
- name: SHA256 Checksummen erstellen
|
||||
run: |
|
||||
cd dist
|
||||
sha256sum *.zip > checksums-sha256.txt
|
||||
cat checksums-sha256.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
- name: GitHub Release erstellen
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: "Hellion NewTab ${{ steps.version.outputs.tag }}"
|
||||
@@ -64,13 +64,13 @@ jobs:
|
||||
- **Firefox:** `hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip`
|
||||
- **Opera / Opera GX:** `hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip`
|
||||
|
||||
See [README](README.md) for the full installation instructions.
|
||||
Siehe [README](README.md) für die vollständige Installationsanleitung.
|
||||
|
||||
### Checksums
|
||||
See `checksums-sha256.txt` to verify file integrity.
|
||||
### Checksummen
|
||||
Siehe `checksums-sha256.txt` zur Integritätsprüfung.
|
||||
files: |
|
||||
dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip
|
||||
dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip
|
||||
dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip
|
||||
dist/checksums-sha256.txt
|
||||
generate_release_notes: true
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -16,14 +16,7 @@ dist/
|
||||
node_modules/
|
||||
/xpi/
|
||||
v2-planning.md
|
||||
themes-v2.md
|
||||
|
||||
# Firefox Update-Manifest (wird auf hellion-media.de gehostet)
|
||||
updates.json
|
||||
|
||||
# Persönliche Backup-Dateien (nicht ins Repo)
|
||||
favorites_*.html
|
||||
*_backup*.json
|
||||
.mcp.json
|
||||
.claude
|
||||
.superpowers/
|
||||
@@ -1,234 +1,92 @@
|
||||
# ⬡ 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.
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] — 2026-04-16
|
||||
|
||||
### Added
|
||||
- **Calculator Tab-System:** 6 Modi über Tab-Leiste erreichbar (Standard, Scientific, Unit, SAT, FAC, STA)
|
||||
- **Scientific-Modus:** Wurzel, Potenz, Pi, Euler, Vorzeichen-Wechsel + Formel-Helfer (Kreis, Pythagoras, Prozent, Temperatur)
|
||||
- **Unit-Converter:** 6 Kategorien (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche) mit Live-Konvertierung und Swap
|
||||
- **Satisfactory Calculator:** Items/Min, Overclock-Power (Exponent 1.321928), Maschinen-Rechner
|
||||
- **Factorio Calculator:** Assembler-Ratios, Belt-Throughput, Maschinen-Rechner mit Belt-Empfehlung
|
||||
- **Stationeers Calculator:** Idealgas (PV=nRT), Furnace/Verbrennung, Solar/Batterie-Dimensionierung, Atmosphären-Mixer
|
||||
|
||||
### Changed
|
||||
- Parser um `^` (Potenz, rechts-assoziativ) und `sqrt()` erweitert
|
||||
- Calculator-Widget Auto-Resize auf 320×480 für komplexe Modi
|
||||
- ~110 neue i18n-Keys (DE + EN)
|
||||
|
||||
---
|
||||
|
||||
### v2.0.1 — 16.04.2026
|
||||
|
||||
#### Security
|
||||
|
||||
- **Background URL validation** — Only `blob:` and `data:image/` protocols allowed in CSS `backgroundImage` (prevents CSS injection via manipulated storage)
|
||||
- **Import URL validation** — `javascript:`, `data:`, and other unsafe protocols are blocked during JSON import
|
||||
- **Immutable import mapping** — Imported boards, bookmarks, and notes are sanitized with explicit field selection and string length limits
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **Widget minimize race condition** — Replaced `setTimeout` with `transitionend` event; `openWidget()` during animation no longer causes display glitch
|
||||
- **Notes import mutation** — Import now uses `Notes.init()` instead of directly setting `Notes._notes`
|
||||
- **Complete i18n coverage** — 5 header button tooltips and 3 settings button texts now have `data-i18n` attributes (10 new translation keys)
|
||||
|
||||
#### Changed
|
||||
|
||||
- **Widget event system** — `WidgetManager` now dispatches `widget:close`, `widget:minimize`, `widget:open` CustomEvents via `EventTarget`. Calculator, Timer, and ImageRef use `WidgetManager.on()` instead of monkey-patching
|
||||
- **Local favicon icons** — Replaced Google Favicons API with local colored letter icons (deterministic hue per title). Zero external network requests, Brave Shields compatible
|
||||
- **backdrop-filter fallback** — `@supports not (backdrop-filter)` block with `--bg-solid-fallback` per theme for Brave Shields compatibility
|
||||
- **Clock interval cleanup** — `setInterval` ID stored in variable
|
||||
|
||||
---
|
||||
|
||||
### v2.0.0 — 22.03.2026
|
||||
|
||||
#### New Features
|
||||
|
||||
- **Internationalization (i18n)** — Full DE/EN language support with runtime switching
|
||||
- Language setting in Settings panel: German, English or Auto-detect (browser language)
|
||||
- `i18n.js` module with ~220+ string keys, `t(key, vars?)` helper and `data-i18n` HTML attributes
|
||||
- `_locales/de/` and `_locales/en/` for manifest-level i18n (`__MSG_extName__`, `__MSG_extDesc__`)
|
||||
- `<html lang>` attribute updates dynamically when language changes
|
||||
- All modules migrated: dialog, boards, onboarding, notes, calculator, timer, image-ref, data, bookmark-import, storage, settings, widgets, app
|
||||
|
||||
#### Technical
|
||||
|
||||
- New script load order: `storage → state → i18n → dialog → ...`
|
||||
- `applyLanguage()` scans DOM for `data-i18n`, `data-i18n-placeholder`, `data-i18n-title`
|
||||
- Onboarding slides use i18n keys instead of hardcoded text (rendered at display time)
|
||||
- Clock day/month names via i18n keys instead of hardcoded arrays
|
||||
- `resolveLang()` helper for DRY language resolution (auto → browser detect)
|
||||
|
||||
---
|
||||
|
||||
### v1.10.0 — 22.03.2026
|
||||
|
||||
#### Themes
|
||||
|
||||
- **3 new themes** — Satisfactory (Industrial Desert), Avorion (Deep Void) and Hellion Stealth (Tactical Recon)
|
||||
- Now **11 themes** total, each with its own accent colors, overlays and font styles
|
||||
- Satisfactory has increased board alpha (0.65) and stronger blur (12px), a deliberate choice for better readability on a visually busy background
|
||||
- Avorion uses a radial gradient overlay so the ship in the center of the image stays visible
|
||||
- Hellion Stealth is the only theme with a `border-left` hover effect in tactical scanner style
|
||||
|
||||
---
|
||||
|
||||
### v1.9.0 — 22.03.2026
|
||||
|
||||
#### New Features
|
||||
|
||||
- **Onboarding reworked** — 7 slides instead of 6, new slide explains the widget toolbar with all widgets
|
||||
- **Gaming Starter Board** — Opt-in during onboarding: pre-filled board with links to Satisfactory, Factorio, Avorion, Minecraft and Star Citizen
|
||||
- **Settings redesign** — Settings panel slimmed down to 3 sections (Widgets, Data & Help, Danger Zone)
|
||||
- **Appearance modal** — Theme picker and all display settings combined in one modal instead of spread across the panel
|
||||
- **Fixed about footer** — Developer info, license and links are now permanently visible at the bottom of the settings panel
|
||||
- **Project documentation** — `docs/architecture.md`, `docs/widget-schema.md` and `docs/patterns.md` for anyone who wants to fork or contribute
|
||||
|
||||
#### Improvements
|
||||
|
||||
- All labels and descriptions unified in German, no more language mix
|
||||
- Dropdown options use theme colors instead of white browser default
|
||||
- Firefox update URL for store publishing added to `manifest.firefox.json`
|
||||
|
||||
---
|
||||
|
||||
### v1.8.0 — 21.03.2026
|
||||
|
||||
#### New Features
|
||||
|
||||
- **Image Reference Widget** — Drop images as floating reference widgets (max. 3 at once)
|
||||
- Canvas API WebP conversion for smaller file sizes, all local in the browser
|
||||
- Two-layer storage: metadata persistent, image data session-only (sessionStorage)
|
||||
- Load images via drag & drop or file dialog
|
||||
- Labels editable with debounced save
|
||||
- Feature is off by default, enable via Settings → Widgets
|
||||
|
||||
---
|
||||
|
||||
### v1.7.1 — 21.03.2026
|
||||
|
||||
#### Improvements
|
||||
|
||||
- **Timer mute toggle** — Alarm can be muted via icon button without restarting the timer
|
||||
- Alarm volume reduced to 7%, 30% was a bit much
|
||||
- Mute state is saved and persists on next open
|
||||
|
||||
---
|
||||
|
||||
### v1.7.0 — 21.03.2026
|
||||
|
||||
#### New Features
|
||||
|
||||
- **Calculator widget** — Shunting-yard parser (no `eval()`), history of last calculations, keyboard input
|
||||
- **Timer/countdown widget** — Saveable presets, Web Audio API alarm, tab title blinks when timer completes
|
||||
- **Widget z-index fix** — Widgets now correctly render above the search bar (z-index 100+)
|
||||
|
||||
---
|
||||
|
||||
### v1.6.0 — 21.03.2026
|
||||
|
||||
#### New Features
|
||||
|
||||
- **Widget system** — Draggable, resizable floating panels managed by WidgetManager
|
||||
- **Notes & checklists** — Multi-instance widgets (max. 5) with text and checklist template, Markdown support, export as `.md`
|
||||
- **Notebook sidebar** — All notes at a glance, quick access via toolbar
|
||||
- **Widget toolbar** — Floating buttons on the side for quick access to all widgets, position (left/right) configurable in Settings
|
||||
- **Sticky note migration** — Old sticky notes are automatically migrated to the new widget system on first launch
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Ko-fi support link added to the about section and `FUNDING.yml`
|
||||
Alle relevanten Änderungen pro Version. Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
||||
|
||||
---
|
||||
|
||||
### v1.5.2 — 21.03.2026
|
||||
|
||||
#### New Features
|
||||
#### Neue 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)
|
||||
- **Custom Dialog-System** — Native `confirm()` und `alert()` durch Frosted-Glass-Dialoge ersetzt (`dialog.js`)
|
||||
- **Onboarding** — 6-stufiger Willkommens-Flow beim ersten Start mit Boards, Themes, Features und Backup-Hinweis
|
||||
- **Backup-Reminder** — Erinnert alle 7 Tage an JSON-Export, warnt vor Datenverlust bei Browser-Reset
|
||||
- **Theme-Modal** — Theme-Picker als eigenes Modal aus Settings ausgelagert, eigener Header-Button
|
||||
- **Accordion-Settings** — Alle Settings-Sektionen einklappbar mit Chevron (About/Danger Zone standardmäßig zu)
|
||||
|
||||
#### Improvements
|
||||
#### Verbesserungen
|
||||
|
||||
- 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
|
||||
- Fonts von Google Fonts API auf lokale WOFF2-Dateien umgestellt (DSGVO)
|
||||
- Ungenutzte Font-Dateien entfernt (~388 KB gespart)
|
||||
- `innerHTML` komplett durch `createElement`/`createElementNS` ersetzt (XSS-Schutz)
|
||||
- SVG-Icons via `createElementNS` statt Inline-HTML
|
||||
- Drag & Drop: Inline-Styles durch CSS-Klassen ersetzt (`.drag-ghost`, `.drag-over`, `.dragging-source`)
|
||||
- Suchleisten-Toggle von DATA nach BEHAVIOR verschoben
|
||||
- Nicht implementiertes "Quick Save" UI-Element entfernt
|
||||
- Onboarding wiederholbar über Settings → Help
|
||||
|
||||
#### Opera / Opera GX
|
||||
|
||||
- `manifest.opera.json` 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`
|
||||
- `manifest.opera.json` hinzugefügt (MV3 mit Workaround-Skripten)
|
||||
- `src/js/opera/background.js` — Tab-Management gegen Opera Speed Dial
|
||||
- `src/js/opera/redirect.js` — Content Script Redirect bei `document_start`
|
||||
|
||||
#### Firefox
|
||||
|
||||
- `manifest.firefox.json` migrated to Manifest V3
|
||||
- `browser_specific_settings` with Gecko ID and `data_collection_permissions` added
|
||||
- `manifest.firefox.json` auf Manifest V3 migriert
|
||||
- `browser_specific_settings` mit Gecko-ID und `data_collection_permissions`
|
||||
|
||||
#### Build & CI
|
||||
|
||||
- GitHub Actions release now builds 3 ZIP packages (Chrome, Firefox, Opera)
|
||||
- Quality check validates all 3 manifests and the Opera folder
|
||||
- GitHub Actions: Release erstellt jetzt 3 ZIP-Pakete (Chrome, Firefox, Opera)
|
||||
- Quality-Check prüft alle 3 Manifests und Opera-Ordner
|
||||
|
||||
---
|
||||
|
||||
### v1.2.0 — 20.03.2026
|
||||
|
||||
- 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
|
||||
- Projektstruktur in `src/js/`, `src/css/`, `assets/` aufgeteilt
|
||||
- JS in 10 Module aufgeteilt (storage, state, themes, boards, drag, settings, search, sticky, data, app)
|
||||
- Firefox-Kompatibilität (`manifest.firefox.json`, Manifest V3)
|
||||
- Vivaldi bestätigt kompatibel
|
||||
- Theme-Bildpfade korrigiert (Settings Preview)
|
||||
- URL-Validierung bei Bookmark-Erstellung
|
||||
- JSON-Import mit Board- und Bookmark-Struktur-Validierung
|
||||
- XSS-Schutz: `createElement` statt `innerHTML` für Bookmarks
|
||||
- Storage-Quota-Prüfung mit Warnung bei 8 MB+
|
||||
- Event Delegation für Bookmark-Klicks (Performance)
|
||||
- Responsive Design (Tablet 768px, Smartphone 480px)
|
||||
- Sticky Note Header-Kollision behoben
|
||||
- FileReader-Fehlerbehandlung für Hintergrundbild-Upload
|
||||
- GitHub Actions: Security Scan, Code Quality, Release Automation
|
||||
- 3 Themes ersetzt: Astronaut → Nebula, Cosmic Clock → Crescent, Void Mage → Event Horizon
|
||||
- Alle Theme-Bilder lizenzrechtlich geprüft und dokumentiert
|
||||
- LICENSE (CC BY-NC-SA 4.0), SECURITY.md und DISCLAIMER.md hinzugefügt
|
||||
|
||||
---
|
||||
|
||||
### v1.1.0 — 20.03.2026
|
||||
|
||||
- 5 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
|
||||
- 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
|
||||
|
||||
- Initial release
|
||||
- Boards & bookmarks with drag & drop
|
||||
- 3 themes (Nebula, Crescent, Event Horizon)
|
||||
- HTML import (browser bookmarks)
|
||||
- Settings panel
|
||||
- Initiales Release
|
||||
- Boards & Bookmarks mit Drag & Drop
|
||||
- 3 Themes (Nebula, Crescent, Event Horizon)
|
||||
- HTML-Import (Browser-Lesezeichen)
|
||||
- Settings Panel
|
||||
|
||||
---
|
||||
|
||||
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
|
||||
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
|
||||
@@ -1,81 +1,47 @@
|
||||
# Disclaimer — Hellion NewTab
|
||||
# Haftungsausschluss — Hellion NewTab
|
||||
|
||||
## Use at Your Own Risk
|
||||
## Nutzung auf eigenes Risiko
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## No Guarantee
|
||||
## Keine Garantie
|
||||
|
||||
The developer assumes no liability for:
|
||||
Der Entwickler übernimmt keine Haftung für:
|
||||
|
||||
- Data loss caused by storage errors, browser updates or extension uninstallation
|
||||
- Incompatibilities with specific browser versions or operating systems
|
||||
- Damages arising from the use or inability to use this extension
|
||||
- Availability or accuracy of third-party services (Google Favicons API)
|
||||
- Datenverlust durch fehlerhafte Speicherung, Browser-Updates oder Extension-Deinstallation
|
||||
- Inkompatibilitäten mit bestimmten Browser-Versionen oder Betriebssystemen
|
||||
- Schäden, die durch die Nutzung oder Nichtnutzung dieser Extension entstehen
|
||||
- Verfügbarkeit oder Korrektheit von Drittanbieter-Diensten (Google Favicons API)
|
||||
|
||||
## Data Storage
|
||||
## Datenspeicherung
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**Recommendation:** Create regular JSON backups using the export function in Settings.
|
||||
**Empfehlung:** Regelmäßig JSON-Backups über die Export-Funktion erstellen.
|
||||
|
||||
## No Guaranteed Updates
|
||||
## Drittanbieter-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.
|
||||
Diese Extension nutzt folgende externe Dienste:
|
||||
|
||||
## Third-Party Services
|
||||
| Dienst | Zweck | Datenschutz |
|
||||
| --- | --- | --- |
|
||||
| Google Favicons API | Bookmark-Icons laden | Es wird nur die Domain übermittelt, keine vollständige URL |
|
||||
| Google Fonts | Schriftarten (Rajdhani, Inter, Cinzel) | Standardmäßige Google-Fonts-Nutzungsbedingungen |
|
||||
|
||||
| Service | Purpose | Privacy |
|
||||
|---|---|---|
|
||||
| Google Favicons API | Load bookmark icons | Only the domain is transmitted, not the full URL |
|
||||
## Änderungen
|
||||
|
||||
## Trademark
|
||||
Der Entwickler behält sich das Recht vor, diese Extension jederzeit zu ändern, zu aktualisieren oder einzustellen, ohne vorherige Ankündigung.
|
||||
|
||||
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.
|
||||
|
||||
## Legal
|
||||
|
||||
This extension is developed and maintained by Florian Wathling / Hellion Online Media,
|
||||
based in Bad Harzburg, Germany. All matters are handled in accordance with German
|
||||
and EU law, including the General Data Protection Regulation (GDPR / DSGVO).
|
||||
|
||||
For legal inquiries: [hellion-media.de/impressum](https://hellion-media.de/impressum)
|
||||
|
||||
## Use of AI
|
||||
|
||||
**Claude:** Code analysis, bug fixing, documentation and proofreading.
|
||||
**Me:** Architecture, features and logic are planned, thought through and written by me.
|
||||
|
||||
Who looks for "AI patterns" in the code: clean indentation is the linter,
|
||||
okayish variable names are the developer, and the semicolon hiding somewhere
|
||||
is what Claude finds. That's how it works.
|
||||
|
||||
I have ADHD and mild dyslexia. Claude helps me stay focused and makes sure
|
||||
others can follow the code too. That's exactly what open source is for.
|
||||
|
||||
Source code is open, every decision is traceable.
|
||||
|
||||
---
|
||||
## Kontakt
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Developer** | Florian Wathling |
|
||||
| **Company** | Hellion Online Media |
|
||||
| --- | --- |
|
||||
| **Entwickler** | Florian Wathling |
|
||||
| **Unternehmen** | 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) |
|
||||
| **E-Mail** | [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de) |
|
||||
| **Impressum** | [hellion-media.de/impressum](https://hellion-media.de/impressum) |
|
||||
|
||||
---
|
||||
|
||||
**Hellion NewTab** — [Hellion Online Media — Florian Wathling](https://hellion-media.de) — JonKazama-Hellion
|
||||
**Hellion NewTab** — [Hellion Online Media - Florian Wathing](https://hellion-media.de) — JonKazama-Hellion
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
# ⬡ Hellion Dashboard v2.0.0
|
||||
# ⬡ Hellion Dashboard v1.5.2
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](https://ko-fi.com/hellionmedia)
|
||||
|
||||
**No account. No subscription. No cloud. All data stays 100% local.**
|
||||
**Kein Account. Kein Abo. Keine Cloud. Alle Daten bleiben 100% lokal.**
|
||||
|
||||
A personal bookmark dashboard as a browser extension.
|
||||
Boards, drag & drop, 11 themes, search bar, widget system with notes, calculator, timer and more.
|
||||
Full DE/EN language support with runtime switching. All in the browser, all offline.
|
||||
No external data transmission, no trackers, no analytics, no ads.
|
||||
Ein persönlicher Bookmark-Dashboard als Browser-Extension.
|
||||
Boards, Drag & Drop, 8 Themes, Suchleiste, Sticky Notes — alles im Browser, alles offline.
|
||||
Keine externe Datenübertragung, keine Tracker, keine Analytics, keine Werbung.
|
||||
|
||||
Developed by **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion.
|
||||
Entwickelt von **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion.
|
||||
|
||||
---
|
||||
|
||||
## What this extension is NOT
|
||||
## Was diese Extension NICHT ist
|
||||
|
||||
- No cloud sync and no account system
|
||||
- No data collection or telemetry
|
||||
- No third-party dependencies or build tools
|
||||
- No network traffic except favicon fetching (Google Favicons API)
|
||||
- Kein Cloud-Sync und kein Account-System
|
||||
- Keine Datenerfassung oder Telemetrie
|
||||
- Keine Drittanbieter-Abhängigkeiten oder Build-Tools
|
||||
- Kein Netzwerkverkehr außer Favicon-Abruf (Google Favicons API)
|
||||
|
||||
## What this extension IS
|
||||
## Was diese Extension IST
|
||||
|
||||
A local, private NewTab replacement for all major browsers.
|
||||
Bookmarks are stored in `chrome.storage.local`, nothing leaves the browser.
|
||||
What you see is what's saved. No magic.
|
||||
Ein lokaler, privater NewTab-Ersatz für alle gängigen Browser.
|
||||
Bookmarks werden in `chrome.storage.local` gespeichert — nichts verlässt den Browser.
|
||||
Was angezeigt wird, ist was gespeichert ist. Keine Magie.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,30 +36,25 @@ What you see is what's saved. No magic.
|
||||
|
||||
### Boards & Bookmarks
|
||||
|
||||
- Boards as groups for links, sortable via drag & drop
|
||||
- Bookmarks with favicon, title and optional description
|
||||
- Hide boards with the blur button (privacy mode)
|
||||
- HTML import from browser bookmarks (Chrome, Edge, Firefox)
|
||||
- JSON export & import (backup & restore)
|
||||
- Boards als Gruppen für Links — per Drag & Drop umsortierbar
|
||||
- Bookmarks mit Favicon, Titel, optionaler Beschreibung
|
||||
- Boards per Blur-Button verstecken (Privat-Modus)
|
||||
- HTML-Import von Browser-Lesezeichen (Chrome, Edge, Firefox)
|
||||
- JSON Export & Import (Backup & Restore)
|
||||
|
||||
### Search Bar
|
||||
### Suchleiste
|
||||
|
||||
- Google, DuckDuckGo or Bing, switchable with a click
|
||||
- Toggleable via Settings
|
||||
- Google, DuckDuckGo oder Bing — per Klick wechselbar
|
||||
- Ein/ausblendbar über Settings
|
||||
|
||||
### Widget System
|
||||
### Sticky Note
|
||||
|
||||
- **Notes & Checklists** — Floating note widgets with text or checklist template (max. 5)
|
||||
- **Calculator** — Shunting-yard parser (no `eval()`), history, keyboard input
|
||||
- **Timer / Countdown** — Saveable presets, Web Audio API alarm, mute toggle, tab title blinks on completion
|
||||
- **Image Reference** — Images as floating reference widgets, Canvas API WebP conversion (max. 3, enable in Settings)
|
||||
- **Notebook Sidebar** — All notes at a glance
|
||||
- **Widget Toolbar** — Floating buttons for quick access, position (left/right) configurable in Settings
|
||||
- All widgets: draggable, resizable, z-index stacking on click
|
||||
- Schwebendes Notiz-Widget, frei positionierbar
|
||||
- Text und Position werden persistent gespeichert
|
||||
|
||||
### 11 Themes
|
||||
### 8 Themes
|
||||
|
||||
| Theme | Accent | Style |
|
||||
| Theme | Akzent | Stil |
|
||||
|---|---|---|
|
||||
| Nebula | `#b359ff` Magenta | Cosmic Nebula |
|
||||
| Crescent | `#d4bd8a` Gold | Minimalist Night |
|
||||
@@ -70,64 +64,60 @@ What you see is what's saved. No magic.
|
||||
| SC Sunset | `#ff8c3d` Amber | Planet-Side |
|
||||
| Hellion HUD | `#32ff6a` Neon Green | Circuit Board |
|
||||
| Hellion Energy | `#1eff8e` Acid Green | Tactical |
|
||||
| Satisfactory | `#00b4d8` Cyan | Industrial Desert |
|
||||
| Avorion | `#2ec4a0` Turquoise | Deep Void |
|
||||
| Hellion Stealth | `#5ec2ff` Tech Blue | Tactical Recon |
|
||||
|
||||
### Image Credits
|
||||
### Bild-Credits
|
||||
|
||||
| Theme | Source | License |
|
||||
| Theme | Quelle | Lizenz |
|
||||
|---|---|---|
|
||||
| Nebula | [Temel / mrwashingt0n](https://pixabay.com/de/users/mrwashingt0n-15745216/) on Pixabay | Pixabay License (free) |
|
||||
| Crescent | [Daniil Silantev](https://unsplash.com) on Unsplash | Unsplash License (free) |
|
||||
| Event Horizon | Own work, still frame from [hellion-initiative.online](https://hellion-initiative.online) | Hellion Online Media |
|
||||
| Merchantman | [Roberts Space Industries](https://robertsspaceindustries.com), made by the community | RSI Community Content |
|
||||
| SC Sunset | Screenshot from Star Citizen by Cloud Imperium Games | Fan Content |
|
||||
| Julia & Jin | Own work, Final Fantasy XIV screenshot, edited in Photoshop | Hellion Online Media |
|
||||
| Hellion HUD | Own work, AI-generated and post-processed for hellion-media.de | Hellion Online Media |
|
||||
| Hellion Energy | Own work, AI-generated for hellion-media.de | Hellion Online Media |
|
||||
| Satisfactory | Screenshot from Satisfactory by Coffee Stain Studios | Fan Content |
|
||||
| Avorion | Own work, screenshot from Avorion, Hellion Initiative ship | Hellion Online Media |
|
||||
| Hellion Stealth | Screenshot from Star Citizen by Cloud Imperium Games | Fan Content |
|
||||
| Nebula | [Temel / mrwashingt0n](https://pixabay.com/de/users/mrwashingt0n-15745216/) auf Pixabay | Pixabay License (frei) |
|
||||
| Crescent | [Daniil Silantev](https://unsplash.com) auf Unsplash | Unsplash License (frei) |
|
||||
| Event Horizon | Eigenes Werk — Stillframe von [hellion-initiative.online](https://hellion-initiative.online) | Hellion Online Media |
|
||||
| Merchantman | [Roberts Space Industries](https://robertsspaceindustries.com) — Made by the community | RSI Community Content |
|
||||
| SC Sunset | Screenshot aus Star Citizen von Cloud Imperium Games | Fan Content |
|
||||
| Julia & Jin | Eigenes Werk — Final Fantasy XIV Screenshot, bearbeitet in Photoshop | Hellion Online Media |
|
||||
| Hellion HUD | Eigenes Werk — AI-generiert und nachbearbeitet für hellion-media.de | Hellion Online Media |
|
||||
| Hellion Energy | Eigenes Werk — AI-generiert für hellion-media.de | Hellion Online Media |
|
||||
|
||||
### Language Support (i18n)
|
||||
### Onboarding & Dialoge
|
||||
|
||||
- German and English with runtime switching via Settings
|
||||
- Auto-detect from browser language, manual override available
|
||||
- All UI elements, dialogs, onboarding and widget labels fully translated
|
||||
- 6-stufiger Willkommens-Flow beim ersten Start
|
||||
- Custom Frosted-Glass-Dialoge statt nativer Browser-Popups
|
||||
- Backup-Reminder alle 7 Tage (warnt vor Datenverlust bei Browser-Reset)
|
||||
|
||||
### Onboarding & Dialogs
|
||||
### Settings (Accordion)
|
||||
|
||||
- 7-step welcome flow on first launch with widget explanation and optional gaming starter board
|
||||
- Custom frosted glass dialogs instead of native browser popups
|
||||
- Backup reminder every 7 days (warns about data loss on browser reset)
|
||||
- Einklappbare Sektionen mit Chevron — About/Danger Zone standardmäßig geschlossen
|
||||
- Compact Mode — reduziert Abstände für mehr Bookmarks
|
||||
- Shorten Titles — kürzt lange Titel auf eine Zeile
|
||||
- Open in New Tab — Bookmarks in neuem Tab öffnen
|
||||
- Show Descriptions — Beschreibungen unter Bookmarks anzeigen
|
||||
- Hide Extra Bookmarks — Boards ab 5/10/20 Bookmarks einklappen
|
||||
- Suchleiste ein/ausblenden
|
||||
- JSON Export / Import
|
||||
- Onboarding wiederholbar
|
||||
- Danger Zone — Reset aller Daten
|
||||
|
||||
### Appearance & Settings
|
||||
### Theme-Picker (eigener Header-Button)
|
||||
|
||||
- **Appearance modal** (header button), theme picker, background image and all display options in one modal
|
||||
- **Settings panel** (header button), widgets, data & help, danger zone
|
||||
- **About footer**, developer info, license and support links permanently visible
|
||||
- Compact mode, shorten titles, search bar toggle, open links in new tab, descriptions, hide extra bookmarks
|
||||
- JSON export & import (backup & restore)
|
||||
- Onboarding repeatable
|
||||
- Language setting: German, English or auto-detect
|
||||
- 8 Themes als zentriertes Modal
|
||||
- Hintergrundbild per URL oder lokaler Upload
|
||||
|
||||
---
|
||||
|
||||
## Browser Compatibility
|
||||
## Browser-Kompatibilität
|
||||
|
||||
| Browser | Status | Manifest |
|
||||
|---|---|---|
|
||||
| Chrome | ✅ Compatible | V3 (`manifest.json`) |
|
||||
| Edge | ✅ Compatible | V3 (`manifest.json`) |
|
||||
| Brave | ✅ Compatible | V3 (`manifest.json`) |
|
||||
| Opera | ✅ Compatible | V3 (`manifest.opera.json`) |
|
||||
| Opera GX | ✅ Compatible | V3 (`manifest.opera.json`) |
|
||||
| Vivaldi | ✅ Compatible | V3 (`manifest.json`) |
|
||||
| Firefox | ✅ Compatible | V3 (`manifest.firefox.json`) |
|
||||
| Chrome | ✅ Kompatibel | V3 (`manifest.json`) |
|
||||
| Edge | ✅ Kompatibel | V3 (`manifest.json`) |
|
||||
| Brave | ✅ Kompatibel | V3 (`manifest.json`) |
|
||||
| Opera | ✅ Kompatibel | V3 (`manifest.opera.json`) |
|
||||
| Opera GX | ✅ Kompatibel | V3 (`manifest.opera.json`) |
|
||||
| Vivaldi | ✅ Kompatibel | V3 (`manifest.json`) |
|
||||
| Firefox | ✅ Kompatibel | V3 (`manifest.firefox.json`) |
|
||||
|
||||
> **Firefox note:** From v1.2.0 onwards the extension runs on Manifest V3, identical to Chrome/Edge.
|
||||
> `manifest.firefox.json` remains a separate file for Firefox-specific adjustments.
|
||||
> **Firefox-Hinweis:** Ab v1.2.0 läuft die Extension auf Manifest V3 — identisch zu Chrome/Edge.
|
||||
> `manifest.firefox.json` bleibt als separate Datei erhalten für Firefox-spezifische Anpassungen.
|
||||
|
||||
---
|
||||
|
||||
@@ -136,159 +126,144 @@ What you see is what's saved. No magic.
|
||||
### Chrome / Edge / Brave / Vivaldi
|
||||
|
||||
```text
|
||||
1. Download the repository as ZIP or git clone
|
||||
2. Open chrome://extensions (or edge:// / brave://)
|
||||
3. Enable developer mode
|
||||
4. Click "Load unpacked" and select the folder containing manifest.json
|
||||
5. Open a new tab
|
||||
1. Repository als ZIP herunterladen oder git clone
|
||||
2. chrome://extensions öffnen (bzw. edge:// / brave://)
|
||||
3. Entwicklermodus aktivieren
|
||||
4. "Entpackte Erweiterung laden" → Ordner auswählen in dem manifest.json liegt
|
||||
5. Neuen Tab öffnen
|
||||
```
|
||||
|
||||
### Opera / Opera GX
|
||||
|
||||
```bash
|
||||
# Use manifest.opera.json as manifest.json:
|
||||
# manifest.opera.json als manifest.json verwenden:
|
||||
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
|
||||
1. opera://extensions öffnen
|
||||
2. Entwicklermodus aktivieren
|
||||
3. "Entpackte Erweiterung laden" → Ordner auswählen
|
||||
4. Neuen Tab öffnen
|
||||
```
|
||||
|
||||
> **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)
|
||||
> **Opera-Hinweis:** Opera GX priorisiert Speed Dial — der enthaltene Workaround
|
||||
> übernimmt die New-Tab-Seite zuverlässig. Details: [src/js/opera/README.md](src/js/opera/README.md)
|
||||
|
||||
### Firefox
|
||||
|
||||
```bash
|
||||
# Use manifest.firefox.json as manifest.json:
|
||||
# manifest.firefox.json als manifest.json verwenden:
|
||||
copy manifest.firefox.json manifest.json # Windows
|
||||
cp manifest.firefox.json manifest.json # Linux/Mac
|
||||
```
|
||||
|
||||
```text
|
||||
1. Open about:debugging#/runtime/this-firefox
|
||||
2. Click "Load Temporary Add-on"
|
||||
3. Select the manifest.json from the project folder
|
||||
1. about:debugging#/runtime/this-firefox öffnen
|
||||
2. "Temporäres Add-on laden"
|
||||
3. Die manifest.json aus dem Projektordner auswählen
|
||||
```
|
||||
|
||||
> **Note:** Temporary add-ons are removed on browser restart.
|
||||
> For permanent installation a signed `.xpi` file is required.
|
||||
> **Hinweis:** Temporäre Add-ons werden beim Browser-Neustart entfernt.
|
||||
> Für dauerhafte Installation ist eine signierte `.xpi`-Datei nötig.
|
||||
|
||||
---
|
||||
|
||||
## Importing Browser Bookmarks
|
||||
## Browser-Bookmarks exportieren & importieren
|
||||
|
||||
| Browser | Export path |
|
||||
| Browser | Export-Pfad |
|
||||
|---|---|
|
||||
| Chrome / Edge | Settings → Bookmarks → Export bookmarks |
|
||||
| Firefox | Bookmarks → All Bookmarks → Import and Backup → Export Bookmarks to HTML |
|
||||
| Chrome / Edge | Einstellungen → Lesezeichen → Exportieren |
|
||||
| Firefox | Lesezeichen → Alle Lesezeichen → Importieren und Sichern → Als HTML exportieren |
|
||||
|
||||
Load the exported `.html` file via the **Import** button in the extension.
|
||||
Die exportierte `.html`-Datei über den **Import**-Button in der Extension laden.
|
||||
|
||||
---
|
||||
|
||||
## Privacy
|
||||
## Datenschutz
|
||||
|
||||
- No external data transmission (except Google Favicons API for icons)
|
||||
- Storage in `chrome.storage.local` (Chromium) or `browser.storage.local` (Firefox)
|
||||
- No trackers, no analytics, no ads
|
||||
- No cookies, no session data
|
||||
- Storage quota check warns at 8 MB+ (limit: 10 MB)
|
||||
- Permissions: `storage`, `bookmarks` (all browsers) + `tabs` (Opera / Opera GX only)
|
||||
- Keine externe Datenübertragung (außer Google Favicons API für Icons)
|
||||
- Speicherung in `chrome.storage.local` (Chromium) bzw. `browser.storage.local` (Firefox)
|
||||
- Keine Tracker, keine Analytics, keine Werbung
|
||||
- Keine Cookies, keine Session-Daten
|
||||
- Storage-Quota-Prüfung warnt bei 8 MB+ (Limit: 10 MB)
|
||||
- Permissions: `storage`, `bookmarks`
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
## Tech-Stack
|
||||
|
||||
| Component | Details |
|
||||
| Komponente | Details |
|
||||
|---|---|
|
||||
| Language | JavaScript (Vanilla ES2020, no frameworks) |
|
||||
| Styling | CSS Custom Properties (theme system) |
|
||||
| Fonts | Local fonts (Rajdhani, Inter, Cinzel) |
|
||||
| Storage | `chrome.storage.local` / `localStorage` fallback |
|
||||
| Sprache | JavaScript (Vanilla ES2020, keine Frameworks) |
|
||||
| Styling | CSS Custom Properties (Theme-System) |
|
||||
| Fonts | Lokale Fonts (Rajdhani, Inter, Cinzel) |
|
||||
| Storage | `chrome.storage.local` / `localStorage` Fallback |
|
||||
| Favicons | Google Favicons API (`/s2/favicons`) |
|
||||
| Drag & Drop | Pointer Events API (native) |
|
||||
| Build | No build step, runs directly |
|
||||
| CI/CD | GitHub Actions (security, quality, release) |
|
||||
| Drag & Drop | Pointer Events API (nativ) |
|
||||
| Build | Kein Build-Schritt — direkt lauffähig |
|
||||
| CI/CD | GitHub Actions (Security, Quality, Release) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## Architektur
|
||||
|
||||
```text
|
||||
hellion-newtab/
|
||||
├── manifest.json # Chrome, Edge, Brave, Vivaldi (MV3)
|
||||
├── manifest.firefox.json # Firefox (MV3)
|
||||
├── manifest.opera.json # Opera / Opera GX (MV3 + workaround)
|
||||
├── newtab.html # Main HTML (UI structure, modals, settings panel)
|
||||
├── manifest.opera.json # Opera / Opera GX (MV3 + Workaround)
|
||||
├── newtab.html # Haupt-HTML (UI-Struktur, Modals, Settings Panel)
|
||||
├── LICENSE # CC BY-NC-SA 4.0
|
||||
├── CHANGELOG.md # Version history
|
||||
├── SECURITY.md # Security policy and reporting
|
||||
├── DISCLAIMER.md # Disclaimer and legal
|
||||
│
|
||||
├── _locales/
|
||||
│ ├── de/messages.json # Manifest-level i18n (German)
|
||||
│ └── en/messages.json # Manifest-level i18n (English)
|
||||
├── CHANGELOG.md # Versionshistorie
|
||||
├── SECURITY.md # Sicherheitsrichtlinie und Meldeprozess
|
||||
├── DISCLAIMER.md # Haftungsausschluss
|
||||
│
|
||||
├── src/
|
||||
│ ├── js/
|
||||
│ │ ├── storage.js # Storage abstraction + quota check
|
||||
│ │ ├── state.js # Global state, defaults, helpers
|
||||
│ │ ├── i18n.js # Internationalization (DE/EN, ~220+ keys, t() helper)
|
||||
│ │ ├── dialog.js # Custom dialog system (HellionDialog.alert/confirm)
|
||||
│ │ ├── themes.js # Theme definitions & application (11 themes)
|
||||
│ │ ├── boards.js # Board/bookmark rendering, event delegation, modals
|
||||
│ │ ├── drag.js # Drag & drop (Pointer Events, board + bookmark)
|
||||
│ │ ├── settings.js # Settings panel, appearance modal, accordion
|
||||
│ │ ├── search.js # Search bar (Google, DuckDuckGo, Bing)
|
||||
│ │ ├── widgets.js # Widget manager (registry, drag, resize, z-index)
|
||||
│ │ ├── notes.js # Notes & checklists (multi-instance, max. 5)
|
||||
│ │ ├── calculator.js # Calculator (shunting-yard, history)
|
||||
│ │ ├── timer.js # Timer/countdown (presets, Web Audio alarm)
|
||||
│ │ ├── image-ref.js # Image reference widget (Canvas API, sessionStorage)
|
||||
│ │ ├── data.js # JSON export / import with validation
|
||||
│ │ ├── onboarding.js # 7-step welcome flow + gaming board
|
||||
│ │ ├── app.js # Init, clock, global events (entry point)
|
||||
│ │ └── opera/ # Opera GX workaround scripts
|
||||
│ │ ├── background.js # Tab management against Speed Dial
|
||||
│ │ └── redirect.js # Content script redirect
|
||||
│ │ ├── storage.js # Storage Abstraction + Quota-Prüfung
|
||||
│ │ ├── state.js # Globaler State, Defaults, Hilfsfunktionen
|
||||
│ │ ├── dialog.js # Custom Dialog-System (HellionDialog.alert/confirm)
|
||||
│ │ ├── themes.js # Theme-Definitionen & Anwendungslogik
|
||||
│ │ ├── boards.js # Board/Bookmark Rendering, Event Delegation, Modals
|
||||
│ │ ├── drag.js # Drag & Drop (Pointer Events, Board + Bookmark)
|
||||
│ │ ├── settings.js # Settings Panel, Theme-Modal, Accordion
|
||||
│ │ ├── search.js # Suchleiste (Google, DuckDuckGo, Bing)
|
||||
│ │ ├── sticky.js # Sticky Note Widget (Drag, Persist, Toggle)
|
||||
│ │ ├── data.js # JSON Export / Import mit Validierung
|
||||
│ │ ├── onboarding.js # Mehrstufiger Willkommens-Flow
|
||||
│ │ ├── app.js # Init, Clock, globale Events (Einstiegspunkt)
|
||||
│ │ └── opera/ # Opera GX Workaround-Skripte
|
||||
│ │ ├── background.js # Tab-Management gegen Speed Dial
|
||||
│ │ └── redirect.js # Content Script Redirect
|
||||
│ └── css/
|
||||
│ └── main.css # Styles + 11 themes + responsive breakpoints
|
||||
│ └── main.css # Styles + Theme-System + Responsive Breakpoints
|
||||
│
|
||||
├── assets/
|
||||
│ ├── fonts/ # Local fonts (Rajdhani, Inter, Cinzel)
|
||||
│ ├── themes/ # 11 theme background images (WebP only)
|
||||
│ ├── fonts/ # Lokale Fonts (Rajdhani, Inter, Cinzel)
|
||||
│ ├── themes/ # 8 Theme-Hintergrundbilder
|
||||
│ └── icons/
|
||||
│ ├── icon16.png
|
||||
│ ├── icon48.png
|
||||
│ └── icon128.png
|
||||
│
|
||||
├── docs/
|
||||
│ ├── architecture.md # Project architecture and init sequence
|
||||
│ ├── widget-schema.md # Widget system API and schema reference
|
||||
│ ├── patterns.md # Code patterns and conventions
|
||||
│ └── style-guide.md # Design system and theme documentation
|
||||
│
|
||||
└── .github/
|
||||
└── workflows/
|
||||
├── security.yml # CodeQL analysis + dependency review
|
||||
├── quality.yml # Structure, manifest, syntax, version consistency
|
||||
└── release.yml # ZIP packages (Chrome + Firefox + Opera) + SHA256
|
||||
├── security.yml # CodeQL-Analyse + Dependency Review
|
||||
├── quality.yml # Struktur, Manifest, Syntax, Versions-Konsistenz
|
||||
└── release.yml # ZIP-Pakete (Chrome + Firefox + Opera) + SHA256
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
### Design-Prinzipien
|
||||
|
||||
- **Zero Dependencies** — No npm, no build, no framework. Runs directly
|
||||
- **Privacy First** — All data local, no server contact
|
||||
- **Modular** — 16 JS files with clear responsibilities
|
||||
- **Responsive** — Tablet (768px) and smartphone (480px) breakpoints
|
||||
- **Secure** — `createElement` instead of `innerHTML`, URL validation, storage error handling
|
||||
- **Event Delegation** — One listener per board list instead of per bookmark (performance)
|
||||
- **Theme System** — CSS Custom Properties, 11 themes, custom background support
|
||||
- **Zero Dependencies** — Kein npm, kein Build, kein Framework. Direkt lauffähig
|
||||
- **Privacy First** — Alle Daten lokal, kein Server-Kontakt
|
||||
- **Modular** — 12 JS-Dateien mit klarer Zuständigkeit
|
||||
- **Responsive** — Tablet (768px) und Smartphone (480px) Breakpoints
|
||||
- **Secure** — `createElement` statt `innerHTML`, URL-Validierung, Storage-Fehlerbehandlung
|
||||
- **Event Delegation** — Ein Listener pro Board-Liste statt pro Bookmark (Performance)
|
||||
- **Theme-System** — CSS Custom Properties, 8 Themes, Custom-Background-Support
|
||||
|
||||
---
|
||||
|
||||
@@ -296,88 +271,85 @@ hellion-newtab/
|
||||
|
||||
### Security Scan (`security.yml`)
|
||||
|
||||
- **CodeQL analysis** — Static security analysis for JavaScript
|
||||
- **Dependency review** — Checks pull requests for known vulnerabilities
|
||||
- **Schedule** — Automatically weekly (Monday 06:00 UTC) + on push/PR
|
||||
- **CodeQL-Analyse** — Statische Sicherheitsanalyse für JavaScript
|
||||
- **Dependency Review** — Prüft Pull Requests auf bekannte Schwachstellen
|
||||
- **Zeitplan** — Automatisch wöchentlich (Montag 06:00 UTC) + bei Push/PR
|
||||
|
||||
### Code Quality (`quality.yml`)
|
||||
|
||||
- **Project structure** — All required files and folders present
|
||||
- **Manifest validation** — JSON syntax, version, permissions
|
||||
- **JavaScript syntax check** — `node --check` for all JS files
|
||||
- **Version consistency** — manifest.json, manifest.firefox.json and newtab.html must match
|
||||
- **Icon check** — All extension icons present
|
||||
- **Projektstruktur** — Alle Pflichtdateien und -ordner vorhanden
|
||||
- **Manifest-Validierung** — JSON-Syntax, Version, Permissions
|
||||
- **JavaScript Syntax-Check** — `node --check` für alle JS-Dateien
|
||||
- **Versions-Konsistenz** — manifest.json, manifest.firefox.json und newtab.html müssen übereinstimmen
|
||||
- **Icon-Prüfung** — Alle Extension-Icons vorhanden
|
||||
|
||||
### Release (`release.yml`)
|
||||
|
||||
- **Trigger** — On Git tag (`v*`)
|
||||
- **Packages** — Chrome ZIP + Firefox ZIP + Opera ZIP (all MV3)
|
||||
- **Checksums** — SHA256 for all artifacts
|
||||
- **GitHub Release** — Automatic with installation instructions
|
||||
- **Trigger** — Bei Git-Tag (`v*`)
|
||||
- **Pakete** — Chrome-ZIP + Firefox-ZIP + Opera-ZIP (alle MV3)
|
||||
- **Checksummen** — SHA256 für alle Artefakte
|
||||
- **GitHub Release** — Automatisch mit Installationsanleitung
|
||||
|
||||
```bash
|
||||
# Create a release:
|
||||
git tag v2.0.0
|
||||
git push origin v2.0.0
|
||||
# → GitHub Action automatically creates release with ZIP files
|
||||
# Release erstellen:
|
||||
git tag v1.5.2
|
||||
git push origin v1.5.2
|
||||
# → GitHub Action erstellt automatisch Release mit ZIP-Dateien
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
# Repository klonen
|
||||
git clone https://github.com/JonKazama-Hellion/Hellion-NewTab.git
|
||||
|
||||
# Load the extension in your browser (see Installation)
|
||||
# Extension im Browser laden (siehe Installation)
|
||||
|
||||
# After changes: reload the extension
|
||||
chrome://extensions → Hellion NewTab → Reload
|
||||
# Nach Änderungen: Extension neu laden
|
||||
chrome://extensions → Hellion NewTab → Neu laden
|
||||
```
|
||||
|
||||
No build step needed. Change files, reload extension, done.
|
||||
Kein Build-Schritt nötig. Dateien ändern, Extension neu laden, fertig.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
## Sicherheit
|
||||
|
||||
Please do **not** report security vulnerabilities through public GitHub issues.
|
||||
Details on reporting, response times and security architecture: [SECURITY.md](SECURITY.md)
|
||||
Sicherheitslücken bitte **nicht** über öffentliche Issues melden.
|
||||
Details zur Meldung, Reaktionszeiten und Sicherheitsarchitektur: [SECURITY.md](SECURITY.md)
|
||||
|
||||
---
|
||||
|
||||
## License & Legal
|
||||
## Lizenz & Impressum
|
||||
|
||||
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
|
||||
|
||||
- Free for private use
|
||||
- Sharing and modification allowed with attribution
|
||||
- Commercial use without permission prohibited
|
||||
- Kostenlos für private Nutzung
|
||||
- Teilen und Modifikation erlaubt mit Namensnennung
|
||||
- Kommerzielle Nutzung ohne Erlaubnis verboten
|
||||
|
||||
Full license: [LICENSE](LICENSE) | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/)
|
||||
Vollständige Lizenz: [LICENSE](LICENSE) | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Developer** | Florian Wathling |
|
||||
| **Company** | Hellion Online Media |
|
||||
| **Entwickler** | Florian Wathling |
|
||||
| **Unternehmen** | Hellion Online Media |
|
||||
| **Web** | [hellion-media.de](https://hellion-media.de) |
|
||||
| **Imprint** | [hellion-media.de/impressum](https://hellion-media.de/impressum) |
|
||||
| **Impressum** | [hellion-media.de/impressum](https://hellion-media.de/impressum) |
|
||||
| **Bug Reports** | [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=Hellion%20NewTab%20%E2%80%93%20Bug%20Report) |
|
||||
| **Security** | [SECURITY.md](SECURITY.md) |
|
||||
| **Support** | [Ko-fi](https://ko-fi.com/hellionmedia) |
|
||||
|
||||
---
|
||||
|
||||
### Use of AI
|
||||
### Einsatz von AI
|
||||
|
||||
**Claude:** Code analysis, bug fixing, documentation and proofreading.
|
||||
**Me:** Architecture, features and logic are planned, thought through and written by me.
|
||||
|
||||
Details: [DISCLAIMER.md](DISCLAIMER.md)
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
> Full version history: [CHANGELOG.md](CHANGELOG.md)
|
||||
> Vollständige Versionshistorie: [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
|
||||
**Hellion NewTab** — [Hellion Online Media](https://hellion-media.de) — JonKazama-Hellion
|
||||
@@ -1,92 +1,76 @@
|
||||
# Security Policy — Hellion NewTab
|
||||
# Sicherheitsrichtlinie — Hellion NewTab
|
||||
|
||||
## Supported Versions
|
||||
## Unterstützte Versionen
|
||||
|
||||
| Version | Status |
|
||||
|---|---|
|
||||
| 1.9.x | Actively supported |
|
||||
| < 1.9.0 | Not supported |
|
||||
| --- | --- |
|
||||
| 1.2.x | Aktiv unterstützt |
|
||||
| < 1.2.0 | Nicht unterstützt |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
## Sicherheitslücke melden
|
||||
|
||||
If you find a security vulnerability in Hellion NewTab, please **do not** open a public GitHub issue.
|
||||
Wenn du eine Sicherheitslücke in Hellion NewTab findest, melde sie bitte **nicht** über ein öffentliches GitHub Issue.
|
||||
|
||||
### Contact
|
||||
### Kontakt
|
||||
|
||||
**Email:** [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=Hellion%20NewTab%20%E2%80%93%20Security%20Report)
|
||||
**E-Mail:** [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=Hellion%20NewTab%20%E2%80%93%20Security%20Report)
|
||||
|
||||
Please include the following information:
|
||||
Bitte folgende Informationen angeben:
|
||||
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Affected version(s)
|
||||
- Potential impact (data loss, XSS, etc.)
|
||||
- Beschreibung der Schwachstelle
|
||||
- Schritte zur Reproduktion
|
||||
- Betroffene Version(en)
|
||||
- Mögliche Auswirkungen (Datenverlust, XSS, etc.)
|
||||
|
||||
### Response Times
|
||||
### Reaktionszeit
|
||||
|
||||
- **Acknowledgement:** Within 48 hours
|
||||
- **Initial assessment:** Within 7 days
|
||||
- **Fix:** Depends on severity, target within 14 days
|
||||
- **Bestätigung:** Innerhalb von 48 Stunden
|
||||
- **Ersteinschätzung:** Innerhalb von 7 Tagen
|
||||
- **Fix:** Abhängig von Schweregrad, Ziel innerhalb von 14 Tagen
|
||||
|
||||
### Severity Levels
|
||||
### Schweregrad-Einstufung
|
||||
|
||||
| Level | Description | Example |
|
||||
|---|---|---|
|
||||
| Critical | Data loss or remote code execution | Storage manipulation by third parties |
|
||||
| High | XSS or unintended data transmission | Script injection via bookmark import |
|
||||
| Medium | UI protection bypass | Blur bypass, settings manipulation |
|
||||
| Low | Cosmetic or theoretical | Edge cases without practical impact |
|
||||
| Stufe | Beschreibung | Beispiel |
|
||||
| --- | --- | --- |
|
||||
| Kritisch | Datenverlust oder Remote Code Execution | Storage-Manipulation durch Dritte |
|
||||
| Hoch | XSS oder ungewollte Datenübertragung | Script-Injection via Bookmark-Import |
|
||||
| Mittel | Umgehung von UI-Schutzmechanismen | Blur-Bypass, Settings-Manipulation |
|
||||
| Niedrig | Kosmetisch oder theoretisch | Edge-Cases ohne praktische Auswirkung |
|
||||
|
||||
---
|
||||
## Sicherheitsarchitektur
|
||||
|
||||
## Security Architecture
|
||||
### Datenverarbeitung
|
||||
|
||||
### Data Handling
|
||||
- **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
|
||||
|
||||
- **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
|
||||
### Eingabe-Validierung
|
||||
|
||||
### 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+
|
||||
- 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
|
||||
|
||||
This extension requests the following browser permissions:
|
||||
Diese Extension benötigt nur zwei 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` |
|
||||
| Permission | Grund |
|
||||
| --- | --- |
|
||||
| `storage` | Boards, Settings und Sticky Note lokal speichern |
|
||||
| `bookmarks` | Browser-Lesezeichen für HTML-Import lesen |
|
||||
|
||||
No permissions requested for: history, web requests, downloads, clipboard or host access.
|
||||
Keine Permissions für: Tabs, History, Web Requests, Downloads, Clipboard oder Host-Zugriff.
|
||||
|
||||
### CI/CD Security
|
||||
### CI/CD-Sicherheit
|
||||
|
||||
- **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
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
**Hellion Dashboard** — [Hellion Online Media - Florian Wathling](https://hellion-media.de) — JonKazama-Hellion
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extName": { "message": "Hellion NewTab" },
|
||||
"extDesc": { "message": "Persönliches Bookmark-Dashboard mit Boards, Widgets und 11 Themes. Komplett lokal, keine Cloud, kein Tracking." }
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extName": { "message": "Hellion NewTab" },
|
||||
"extDesc": { "message": "Personal bookmark dashboard with boards, widgets, and 11 themes. Local-only, no cloud, no tracking." }
|
||||
}
|
||||
|
Before Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
Before Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 76 KiB |
@@ -1,316 +0,0 @@
|
||||
# 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
|
||||
@@ -1,175 +0,0 @@
|
||||
# Hellion Dashboard — Architecture
|
||||
|
||||
> This document is intentionally written in English. Full German/English i18n support
|
||||
> is planned for v2.0 — until then, English keeps the docs accessible to anyone
|
||||
> who wants to contribute or fork the project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Hellion Dashboard is a browser extension (NewTab replacement) built with **Vanilla JavaScript ES2020**, **CSS Custom Properties**, and **zero dependencies**. No build step, no framework, no bundler — files are loaded directly via `<script>` tags.
|
||||
|
||||
**Storage:** `chrome.storage.local` with `localStorage` fallback.
|
||||
**Manifest:** V3 across all supported browsers (separate files for Firefox and Opera GX).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
HOM_NewTab_Project/
|
||||
├── newtab.html # Single HTML entry point
|
||||
├── manifest.json # Chrome, Edge, Brave, Vivaldi (MV3)
|
||||
├── manifest.firefox.json # Firefox (MV3)
|
||||
├── manifest.opera.json # Opera, Opera GX (MV3 + workarounds)
|
||||
├── src/
|
||||
│ ├── css/
|
||||
│ │ └── main.css # All styles, 11 themes, responsive breakpoints
|
||||
│ └── js/
|
||||
│ ├── storage.js # Storage abstraction layer
|
||||
│ ├── state.js # Global state, defaults, helpers
|
||||
│ ├── i18n.js # Internationalization (DE/EN, t() helper)
|
||||
│ ├── dialog.js # Custom dialog system (alert, confirm)
|
||||
│ ├── themes.js # Theme definitions & application (11 themes)
|
||||
│ ├── drag.js # Drag & drop (Pointer Events API)
|
||||
│ ├── boards.js # Board/bookmark rendering & events
|
||||
│ ├── settings.js # Settings panel, toggles, theme picker
|
||||
│ ├── search.js # Search bar (Google, DuckDuckGo, Bing)
|
||||
│ ├── widgets.js # Widget manager (registry, drag, resize)
|
||||
│ ├── notes.js # Notes/checklists (multi-instance widgets)
|
||||
│ ├── calculator.js # Calculator widget (single-instance)
|
||||
│ ├── timer.js # Timer/countdown widget (single-instance)
|
||||
│ ├── image-ref.js # Image reference widget (multi-instance)
|
||||
│ ├── bookmark-import.js # Browser bookmark import (chrome.bookmarks API)
|
||||
│ ├── data.js # JSON export/import (backup & restore)
|
||||
│ ├── onboarding.js # First-run onboarding flow
|
||||
│ └── app.js # Init, clock, global events (entry point)
|
||||
├── assets/
|
||||
│ ├── fonts/ # Local fonts (Rajdhani, Inter, Cinzel)
|
||||
│ ├── icons/ # Extension icons (16-512px)
|
||||
│ └── themes/ # 11 theme background images
|
||||
└── docs/ # You are here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Responsibilities
|
||||
|
||||
Each module has exactly one responsibility. Communication happens through global references — no import/export, because this is a browser extension without a bundler.
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `storage.js` | The **only** place that touches `chrome.storage` / `localStorage`. Everything else goes through `Store.get()` / `Store.set()`. |
|
||||
| `state.js` | Global `boards` and `settings` arrays, default values, `uid()`, `escHtml()`, `getFaviconUrl()`. |
|
||||
| `i18n.js` | Internationalization module. `STRINGS` object with ~220+ keys (DE/EN), `t(key, vars?)` helper, `applyLanguage()` DOM scanner, `setLanguage()`, `I18n.init()`. |
|
||||
| `dialog.js` | `HellionDialog.alert()` and `HellionDialog.confirm()` — custom styled dialogs that replace native browser popups. |
|
||||
| `themes.js` | Applies theme CSS variables. 11 themes, each with its own `[data-theme]` block in `main.css`. |
|
||||
| `boards.js` | Renders boards and bookmarks. Event delegation on board containers. |
|
||||
| `drag.js` | Board and bookmark reordering via Pointer Events API. |
|
||||
| `settings.js` | Settings panel UI, toggle handlers, appearance modal, background upload. |
|
||||
| `search.js` | Search bar with engine switching (Google, DuckDuckGo, Bing). |
|
||||
| `widgets.js` | Widget manager — creates DOM, handles drag/resize/z-index, provides registry. See [widget-schema.md](widget-schema.md). |
|
||||
| `notes.js` | Notes and checklists as widgets. Multi-instance (max 5). Notebook sidebar. Also handles widget toolbar events. |
|
||||
| `calculator.js` | Calculator widget. Single-instance. Shunting-yard expression parser — no `eval()`. |
|
||||
| `timer.js` | Timer/countdown widget. Single-instance. Presets, Web Audio API alarm, tab-title blink on completion. |
|
||||
| `image-ref.js` | Image reference widget. Multi-instance (max 3). Canvas API WebP conversion, sessionStorage for image data — cleared on browser close. |
|
||||
| `bookmark-import.js` | Direct browser bookmark import via `chrome.bookmarks.getTree()`. Folder selection modal with duplicate detection. |
|
||||
| `data.js` | JSON export/import with validation. Covers boards, notes, calculator history and timer presets. |
|
||||
| `onboarding.js` | Multi-slide first-run flow including the gaming starter board opt-in. |
|
||||
| `app.js` | Entry point. Calls `init()` on DOMContentLoaded. Clock, global event binding. |
|
||||
|
||||
---
|
||||
|
||||
## Init Sequence
|
||||
|
||||
```
|
||||
DOMContentLoaded
|
||||
→ init()
|
||||
→ Store.get('boards') # Load saved boards
|
||||
→ Store.get('settings') # Load saved settings
|
||||
→ applySettings() # Apply theme, toggles, etc.
|
||||
→ renderBoards() # Render all boards
|
||||
→ startClock() # Start clock/date display
|
||||
→ bindGlobalEvents() # Header buttons, modals
|
||||
→ bindSettingsEvents() # Settings toggles, theme picker
|
||||
→ initSearch() # Search bar
|
||||
→ migrateSticky() # Legacy sticky note migration (v1.5.x → v1.6+)
|
||||
→ Notes.init() # Notes + widget toolbar
|
||||
→ Calculator.init() # Calculator widget
|
||||
→ Timer.init() # Timer widget
|
||||
→ ImageRef.init() # Image reference widget
|
||||
→ initDataButtons() # Export/import buttons
|
||||
→ Onboarding check # First-run onboarding
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Script Load Order
|
||||
|
||||
Scripts are loaded in `newtab.html` in dependency order. A module may only reference modules loaded before it — there is no bundler to handle this automatically.
|
||||
|
||||
```html
|
||||
<script src="src/js/storage.js"></script>
|
||||
<script src="src/js/state.js"></script>
|
||||
<script src="src/js/i18n.js"></script>
|
||||
<script src="src/js/dialog.js"></script>
|
||||
<script src="src/js/themes.js"></script>
|
||||
<script src="src/js/drag.js"></script>
|
||||
<script src="src/js/boards.js"></script>
|
||||
<script src="src/js/settings.js"></script>
|
||||
<script src="src/js/search.js"></script>
|
||||
<script src="src/js/widgets.js"></script>
|
||||
<script src="src/js/notes.js"></script>
|
||||
<script src="src/js/calculator.js"></script>
|
||||
<script src="src/js/timer.js"></script>
|
||||
<script src="src/js/image-ref.js"></script>
|
||||
<script src="src/js/bookmark-import.js"></script>
|
||||
<script src="src/js/data.js"></script>
|
||||
<script src="src/js/onboarding.js"></script>
|
||||
<script src="src/js/app.js"></script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Z-Index Hierarchy
|
||||
|
||||
| Layer | z-index | Elements |
|
||||
|---|---|---|
|
||||
| Background | 0-2 | `#bgLayer`, boards |
|
||||
| Search bar | 90 | `.search-bar-wrapper` |
|
||||
| Widgets + Toolbar | 100+ | `.widget`, `.widget-toolbar` |
|
||||
| Header | 100 | `#header` |
|
||||
| Settings panel | 200 | `#settingsPanel` |
|
||||
| Dialogs / Modals | 300 | `.hellion-dialog-overlay`, modals |
|
||||
| Onboarding | 400 | `#onboardingOverlay` |
|
||||
|
||||
Widgets use an incrementing z-index (`WidgetManager._topZ++`) so the last clicked widget always sits on top.
|
||||
|
||||
---
|
||||
|
||||
## Storage Keys
|
||||
|
||||
| Key | Type | Content |
|
||||
|---|---|---|
|
||||
| `boards` | Array | Board objects with bookmarks |
|
||||
| `settings` | Object | User preferences (theme, toggles, etc.) |
|
||||
| `widgetStates` | Object | All widget data — see [widget-schema.md](widget-schema.md) |
|
||||
| `onboardingDone` | Boolean | Whether the first-run onboarding has been completed |
|
||||
| `lastBackupReminder` | Number | Timestamp of the last backup reminder |
|
||||
|
||||
---
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
| Browser | Engine | Manifest |
|
||||
|---|---|---|
|
||||
| Chrome | Chromium MV3 | `manifest.json` |
|
||||
| Edge | Chromium MV3 | `manifest.json` |
|
||||
| Brave | Chromium MV3 | `manifest.json` |
|
||||
| Vivaldi | Chromium MV3 | `manifest.json` |
|
||||
| Opera / GX | Chromium MV3 | `manifest.opera.json` |
|
||||
| Firefox | Gecko MV3 | `manifest.firefox.json` |
|
||||
|
||||
Any change that touches manifest fields — version numbers, permissions, content scripts —
|
||||
needs to be applied to all three files. The CI quality check will catch it if they drift out of sync.
|
||||
@@ -1,308 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,955 +0,0 @@
|
||||
# Hellion NewTab v2.0.1 Hardening — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Harden v2.0.0 with security fixes, widget event-system refactoring, i18n completeness, and code quality improvements.
|
||||
|
||||
**Architecture:** Foundation-First — build the new widget event system first, then migrate widget modules onto it, then layer security, i18n, and quality fixes. Each task touches isolated files to avoid merge conflicts.
|
||||
|
||||
**Tech Stack:** Vanilla JavaScript ES2020, CSS Custom Properties, Browser Extension Manifest V3, no build step, no npm.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-16-hardening-v2.0.1-design.md`
|
||||
|
||||
**Testing:** No automated test framework. Each task includes manual browser-based verification steps. Load the extension in Chrome (`chrome://extensions` → Developer mode → Load unpacked) after each task.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Tasks | Changes |
|
||||
|---|---|---|
|
||||
| `src/js/widgets.js` | 1, 2 | Add event system (`_emitter`, `on`, `off`), dispatch events in `close`/`minimize`/`openWidget`, replace `setTimeout` with `transitionend` |
|
||||
| `src/js/calculator.js` | 3 | Replace monkey-patching (L692-728) with `WidgetManager.on()` listeners |
|
||||
| `src/js/timer.js` | 3 | Replace monkey-patching (L723-758) with `WidgetManager.on()` listeners |
|
||||
| `src/js/image-ref.js` | 3 | Replace monkey-patching (L463-498) with `WidgetManager.on()` listeners |
|
||||
| `src/js/settings.js` | 4 | Add `isValidBgUrl()`, validate in `applySettings()` and file upload + URL input handlers |
|
||||
| `src/js/data.js` | 5 | Add `isSafeUrl()`, immutable mapping, string length limits, Notes import via `Notes.init()` |
|
||||
| `src/js/state.js` | 6 | Remove `getFaviconUrl()` |
|
||||
| `src/js/boards.js` | 6 | Replace `<img>` favicon with local letter-div |
|
||||
| `src/css/main.css` | 6, 7 | Replace `.bm-favicon`/`.bm-favicon-fallback` with `.bm-favicon-local`, add `@supports not` fallback, add `--bg-solid-fallback` per theme |
|
||||
| `newtab.html` | 8 | Add 5x `data-i18n-title`, 3x `data-i18n` |
|
||||
| `src/js/i18n.js` | 8 | Add 10 new keys to `STRINGS.de` and `STRINGS.en` (8 i18n + 2 bgUrl validation) |
|
||||
| `src/js/app.js` | 9 | Store `setInterval` ID in variable |
|
||||
| `manifest.json` | 9 | Version bump to 2.0.1 |
|
||||
| `manifest.firefox.json` | 9 | Version bump to 2.0.1 |
|
||||
| `manifest.opera.json` | 9 | Version bump to 2.0.1 |
|
||||
| `CHANGELOG.md` | 9 | Add v2.0.1 entry |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Widget Event-System in WidgetManager
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/widgets.js:6-10` (add emitter + on/off)
|
||||
- Modify: `src/js/widgets.js:143-148` (close — dispatch event)
|
||||
|
||||
- [ ] **Step 1: Add event emitter and on/off methods to WidgetManager**
|
||||
|
||||
In `src/js/widgets.js`, add three new properties after `STORAGE_KEY: 'widgetStates',` (line 10):
|
||||
|
||||
```javascript
|
||||
/** @type {EventTarget} Internes Event-System fuer Widget-Lifecycle */
|
||||
_emitter: new EventTarget(),
|
||||
|
||||
/**
|
||||
* Event-Listener registrieren
|
||||
* @param {string} event - z.B. 'widget:close', 'widget:minimize', 'widget:open'
|
||||
* @param {Function} handler
|
||||
*/
|
||||
on(event, handler) {
|
||||
this._emitter.addEventListener(event, handler);
|
||||
},
|
||||
|
||||
/**
|
||||
* Event-Listener entfernen
|
||||
* @param {string} event
|
||||
* @param {Function} handler
|
||||
*/
|
||||
off(event, handler) {
|
||||
this._emitter.removeEventListener(event, handler);
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Dispatch `widget:close` event in close()**
|
||||
|
||||
Replace the `close` method (lines 143-148):
|
||||
|
||||
```javascript
|
||||
close(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry.el.remove();
|
||||
this._widgets.delete(id);
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:close', { detail: { id } }));
|
||||
},
|
||||
```
|
||||
|
||||
Note: The event fires AFTER `el.remove()` and `_widgets.delete()`. Listeners must not access the widget entry.
|
||||
|
||||
- [ ] **Step 3: Verify event system loads without errors**
|
||||
|
||||
Reload the extension in the browser. Open the console (`F12`). Verify:
|
||||
- No JavaScript errors on load
|
||||
- `WidgetManager.on` is a function (type `WidgetManager.on` in console)
|
||||
- `WidgetManager._emitter` is an EventTarget
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/widgets.js
|
||||
git commit -m "refactor(widgets): add EventTarget-based lifecycle event system
|
||||
|
||||
Add _emitter, on(), off() to WidgetManager. Dispatch widget:close event
|
||||
after close(). Foundation for removing monkey-patching from widget modules."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Minimize with transitionend + openWidget event dispatch
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/widgets.js:154-163` (minimize)
|
||||
- Modify: `src/js/widgets.js:169-180` (openWidget)
|
||||
|
||||
- [ ] **Step 1: Replace setTimeout with transitionend in minimize()**
|
||||
|
||||
Replace the `minimize` method (lines 154-163):
|
||||
|
||||
```javascript
|
||||
async minimize(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry.state.open = false;
|
||||
entry._minimizing = true;
|
||||
entry.el.classList.add('widget-minimized');
|
||||
|
||||
entry.el.addEventListener('transitionend', function onEnd(e) {
|
||||
if (e.target !== entry.el) return;
|
||||
entry.el.removeEventListener('transitionend', onEnd);
|
||||
if (entry._minimizing) {
|
||||
entry.el.style.display = 'none';
|
||||
}
|
||||
entry._minimizing = false;
|
||||
});
|
||||
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
|
||||
await this.save();
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add race-condition guard and event dispatch to openWidget()**
|
||||
|
||||
Replace the `openWidget` method (lines 169-180):
|
||||
|
||||
```javascript
|
||||
async openWidget(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry._minimizing = false;
|
||||
entry.state.open = true;
|
||||
entry.el.style.display = 'flex';
|
||||
requestAnimationFrame(() => {
|
||||
entry.el.classList.remove('widget-minimized');
|
||||
});
|
||||
this.bringToFront(id);
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
|
||||
await this.save();
|
||||
},
|
||||
```
|
||||
|
||||
Key change: `entry._minimizing = false` cancels any in-flight minimize transition.
|
||||
|
||||
- [ ] **Step 3: Verify minimize/open animation works**
|
||||
|
||||
Reload extension. Test:
|
||||
1. Create a note → minimize it → verify it fades out and disappears
|
||||
2. Click the note in the widget toolbar to reopen → verify it appears smoothly
|
||||
3. Rapid test: minimize → immediately reopen before animation ends → verify no display glitch (the race condition fix)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/widgets.js
|
||||
git commit -m "fix(widgets): replace setTimeout with transitionend in minimize
|
||||
|
||||
Fixes race condition where openWidget() during the 250ms timeout would
|
||||
be overridden. Uses _minimizing flag to cancel in-flight transitions.
|
||||
Dispatches widget:minimize and widget:open events."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Migrate Calculator, Timer, ImageRef to Event Listeners
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/calculator.js:692-728`
|
||||
- Modify: `src/js/timer.js:723-758`
|
||||
- Modify: `src/js/image-ref.js:463-498`
|
||||
|
||||
- [ ] **Step 1: Replace monkey-patching in calculator.js**
|
||||
|
||||
Replace lines 692-728 (the three monkey-patching blocks in `init()`) with:
|
||||
|
||||
```javascript
|
||||
// Widget-Lifecycle-Events
|
||||
const self = this;
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = false;
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = true;
|
||||
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||
if (body && body.children.length === 0) {
|
||||
self.renderBody(body);
|
||||
}
|
||||
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||
if (entry) self._bindKeyboard(entry.el);
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace monkey-patching in timer.js**
|
||||
|
||||
Replace lines 723-758 (the three monkey-patching blocks in `init()`) with:
|
||||
|
||||
```javascript
|
||||
// Widget-Lifecycle-Events
|
||||
const self = this;
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = false;
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = true;
|
||||
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||
if (body && body.children.length === 0) {
|
||||
self.renderBody(body);
|
||||
}
|
||||
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||
if (entry) self._bindKeyboard(entry.el);
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace monkey-patching in image-ref.js**
|
||||
|
||||
Replace lines 463-498 (the three monkey-patching blocks in `init()`) with:
|
||||
|
||||
```javascript
|
||||
// Widget-Lifecycle-Events
|
||||
const self = this;
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
const isImage = self._images.some(img => img.id === e.detail.id);
|
||||
if (isImage) {
|
||||
self.onClose(e.detail.id);
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
const isImage = self._images.some(img => img.id === e.detail.id);
|
||||
if (isImage) {
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
const imgData = self._images.find(img => img.id === e.detail.id);
|
||||
if (imgData) {
|
||||
const body = WidgetManager.getBody(e.detail.id);
|
||||
if (body && body.children.length === 0) {
|
||||
const dataUrl = self._getSessionImage(e.detail.id);
|
||||
self.renderBody(imgData, body, dataUrl);
|
||||
}
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify all three widget types work**
|
||||
|
||||
Reload extension. Test each widget type:
|
||||
|
||||
1. **Calculator:** Open → type a calculation → minimize → reopen → verify history is still there → close → reopen from toolbar
|
||||
2. **Timer:** Open → set a time → minimize → reopen → verify time is preserved → close
|
||||
3. **Image-Ref:** Enable in Settings → open image widget → add an image → minimize → reopen → verify image displays → close
|
||||
|
||||
Check console for any errors during all operations.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/calculator.js src/js/timer.js src/js/image-ref.js
|
||||
git commit -m "refactor(widgets): migrate Calculator, Timer, ImageRef to event listeners
|
||||
|
||||
Replace monkey-patching of WidgetManager.close/minimize/openWidget with
|
||||
WidgetManager.on() event listeners. Eliminates 3-deep closure chain."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Security — URL Validation in settings.js
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/settings.js:52-95` (applySettings)
|
||||
- Modify: `src/js/settings.js:166-175` (btnApplyBg handler)
|
||||
- Modify: `src/js/settings.js:181-194` (bgFileInput handler)
|
||||
|
||||
- [ ] **Step 1: Add isValidBgUrl() helper**
|
||||
|
||||
Add this function at the top of `settings.js`, after the `closeThemeModal()` function (after line 24):
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Prueft ob eine Background-URL sicher fuer CSS-Einbettung ist.
|
||||
* Erlaubt nur blob: und data:image/ Protokolle (aus File Upload).
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidBgUrl(url) {
|
||||
return typeof url === 'string' && url.length > 0 &&
|
||||
(url.startsWith('blob:') || url.startsWith('data:image/'));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add validation in applySettings()**
|
||||
|
||||
Replace lines 92-94:
|
||||
|
||||
```javascript
|
||||
if (settings.bgUrl) {
|
||||
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
|
||||
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
||||
} else if (settings.bgUrl) {
|
||||
// Ungueltige URL im Storage — bereinigen
|
||||
settings.bgUrl = '';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add validation in the URL-input handler (btnApplyBg)**
|
||||
|
||||
Replace lines 169-175:
|
||||
|
||||
```javascript
|
||||
document.getElementById('btnApplyBg').addEventListener('click', async () => {
|
||||
const url = document.getElementById('bgUrlInput').value.trim();
|
||||
settings.bgUrl = url;
|
||||
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
|
||||
await saveSettings();
|
||||
document.getElementById('bgInputRow').classList.add('hidden');
|
||||
});
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
document.getElementById('btnApplyBg').addEventListener('click', async () => {
|
||||
const url = document.getElementById('bgUrlInput').value.trim();
|
||||
if (url && !isValidBgUrl(url)) {
|
||||
await HellionDialog.alert(t('settings.bg_invalid_url'), { type: 'danger', title: t('settings.bg_invalid_url.title') });
|
||||
return;
|
||||
}
|
||||
settings.bgUrl = url;
|
||||
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
|
||||
await saveSettings();
|
||||
document.getElementById('bgInputRow').classList.add('hidden');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the file upload handler is already safe**
|
||||
|
||||
Read `settings.js:181-194`. The `FileReader.readAsDataURL(file)` produces a `data:image/...` string, which passes `isValidBgUrl()`. The handler at line 186 sets `settings.bgUrl = ev.target.result` — this is already valid output. No change needed here.
|
||||
|
||||
- [ ] **Step 5: Add i18n keys for the validation error dialog**
|
||||
|
||||
These keys will be added in Task 8 together with all other i18n keys. For now, note that we need:
|
||||
- `settings.bg_invalid_url` — "Nur lokale Bilder (Upload) sind als Hintergrund erlaubt." / "Only local images (upload) are allowed as background."
|
||||
- `settings.bg_invalid_url.title` — "Ungültige URL" / "Invalid URL"
|
||||
|
||||
- [ ] **Step 6: Verify background upload still works**
|
||||
|
||||
Reload extension. Test:
|
||||
1. Open Theme Modal → upload a local image → verify it displays as background
|
||||
2. Try entering `javascript:alert(1)` in the URL input → verify it's rejected with a dialog
|
||||
3. Reload → verify the uploaded background persists
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/settings.js
|
||||
git commit -m "fix(security): validate background URL before CSS injection
|
||||
|
||||
Add isValidBgUrl() that only allows blob: and data:image/ protocols.
|
||||
Applied in applySettings() and the manual URL input handler.
|
||||
Prevents CSS injection via manipulated bgUrl storage values."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Security + Quality — Data Import Hardening
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/data.js:33-127`
|
||||
|
||||
- [ ] **Step 1: Add isSafeUrl() helper at top of data.js**
|
||||
|
||||
Add after the `initDataButtons` function declaration (after line 6, before the function body):
|
||||
|
||||
Actually, add it inside the function before the event listeners, right after `if (!btnExport || !btnImport) return;` (after line 10):
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Prueft ob eine URL ein sicheres Protokoll hat.
|
||||
* Blockiert javascript:, data:, vbscript: etc.
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSafeUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the mutable board/bookmark filter with immutable mapping**
|
||||
|
||||
Replace lines 41-52 (the `validBoards` filter block):
|
||||
|
||||
```javascript
|
||||
const validBoards = data.boards
|
||||
.filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
|
||||
.map(b => ({
|
||||
id: b.id || uid(),
|
||||
title: String(b.title).slice(0, 100),
|
||||
blurred: !!b.blurred,
|
||||
bookmarks: b.bookmarks
|
||||
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
|
||||
.map(bm => ({
|
||||
id: bm.id || uid(),
|
||||
title: String(bm.title).slice(0, 200),
|
||||
url: bm.url,
|
||||
desc: String(bm.desc || '').slice(0, 500)
|
||||
}))
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the mutable notes filter with immutable mapping**
|
||||
|
||||
Replace lines 68-71 (the `importNotes` filter):
|
||||
|
||||
```javascript
|
||||
const importNotes = data.notes
|
||||
.filter(n => n && n.id && n.template)
|
||||
.map(n => ({
|
||||
id: n.id,
|
||||
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
|
||||
title: String(n.title || '').slice(0, 200),
|
||||
content: String(n.content || '').slice(0, 5000),
|
||||
x: typeof n.x === 'number' ? n.x : 120,
|
||||
y: typeof n.y === 'number' ? n.y : 80,
|
||||
width: typeof n.width === 'number' ? n.width : 280,
|
||||
height: typeof n.height === 'number' ? n.height : 220,
|
||||
open: n.open !== false,
|
||||
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace direct Notes._notes mutation with Notes.init()**
|
||||
|
||||
Replace lines 76-81:
|
||||
|
||||
```javascript
|
||||
if (toImport.length > 0) {
|
||||
const merged = [...existingNotes, ...toImport];
|
||||
existingWidgets.notes = merged;
|
||||
Notes._notes = merged;
|
||||
notesImported = toImport.length;
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
if (toImport.length > 0) {
|
||||
const merged = [...existingNotes, ...toImport];
|
||||
existingWidgets.notes = merged;
|
||||
notesImported = toImport.length;
|
||||
}
|
||||
```
|
||||
|
||||
Then after line 113 (`await Store.set('widgetStates', existingWidgets);`), add:
|
||||
|
||||
```javascript
|
||||
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
|
||||
if (notesImported > 0) await Notes.init();
|
||||
if (calcImported) await Calculator.load();
|
||||
if (timerImported) await Timer.load();
|
||||
```
|
||||
|
||||
And remove the direct mutations at lines 93 and 107:
|
||||
- Remove: `Calculator._history = existingWidgets.calculator.history;` (line 93)
|
||||
- Remove: `Timer._presets = existingWidgets.timer.presets;` (line 107)
|
||||
|
||||
- [ ] **Step 5: Verify import functionality**
|
||||
|
||||
Reload extension. Test:
|
||||
1. Export current data as JSON
|
||||
2. Edit the exported JSON: add a bookmark with `javascript:alert(1)` URL → import → verify the bad bookmark is silently skipped
|
||||
3. Import a normal JSON backup → verify boards, notes, calculator history, timer presets all appear correctly
|
||||
4. Verify no console errors
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/data.js
|
||||
git commit -m "fix(security): harden JSON import with URL validation and immutable mapping
|
||||
|
||||
Add isSafeUrl() to block javascript:/data: URLs in imported bookmarks.
|
||||
Replace mutable object mutation with immutable .map() and string length limits.
|
||||
Use Notes.init()/Calculator.load()/Timer.load() instead of direct _notes/_history
|
||||
mutation after import."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Remove Google Favicons — Local Letter Icons
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/state.js:36-43` (remove `getFaviconUrl`)
|
||||
- Modify: `src/js/boards.js:218-230` (replace favicon rendering)
|
||||
- Modify: `src/css/main.css:565-571` (replace CSS classes)
|
||||
|
||||
- [ ] **Step 1: Remove getFaviconUrl() from state.js**
|
||||
|
||||
Delete lines 36-43 in `src/js/state.js`:
|
||||
|
||||
```javascript
|
||||
function getFaviconUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=16`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace favicon rendering in boards.js**
|
||||
|
||||
Replace lines 218-230 in `src/js/boards.js`:
|
||||
|
||||
```javascript
|
||||
const favicon = document.createElement('img');
|
||||
favicon.className = 'bm-favicon';
|
||||
favicon.width = 14;
|
||||
favicon.height = 14;
|
||||
favicon.src = getFaviconUrl(bm.url);
|
||||
favicon.addEventListener('error', function() {
|
||||
this.classList.add('hidden');
|
||||
this.nextElementSibling.classList.remove('hidden');
|
||||
});
|
||||
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'bm-favicon-fallback hidden';
|
||||
fallback.textContent = bm.title.charAt(0).toUpperCase();
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
const favicon = document.createElement('div');
|
||||
favicon.className = 'bm-favicon-local';
|
||||
favicon.textContent = bm.title.charAt(0).toUpperCase();
|
||||
const hue = (bm.title.charCodeAt(0) * 137) % 360;
|
||||
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
|
||||
```
|
||||
|
||||
Also update the `appendChild` calls below. The old code appends both `favicon` and `fallback`:
|
||||
|
||||
Find the line that appends the fallback (should be near line 243-244):
|
||||
```javascript
|
||||
li.append(favicon, fallback, textDiv, deleteBtn);
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```javascript
|
||||
li.append(favicon, textDiv, deleteBtn);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace CSS classes in main.css**
|
||||
|
||||
Replace lines 565-571:
|
||||
|
||||
```css
|
||||
.bm-favicon { width: 14px; height: 14px; flex-shrink: 0; border-radius: 2px; opacity: 0.85; }
|
||||
.bm-favicon-fallback {
|
||||
width: 14px; height: 14px; flex-shrink: 0;
|
||||
background: var(--accent-dim); border-radius: 2px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 8px; color: var(--accent);
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```css
|
||||
.bm-favicon-local {
|
||||
width: 16px; height: 16px; flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 9px; font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify favicons display correctly**
|
||||
|
||||
Reload extension. Check:
|
||||
1. All bookmarks show a colored letter icon
|
||||
2. Different bookmark titles produce different colors
|
||||
3. The icons are aligned and properly sized in all themes
|
||||
4. No network requests to google.com in the Network tab (F12 → Network)
|
||||
5. No console errors about `getFaviconUrl`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/state.js src/js/boards.js src/css/main.css
|
||||
git commit -m "feat(privacy): replace Google Favicons with local letter icons
|
||||
|
||||
Remove getFaviconUrl() and all external network requests. Bookmarks now
|
||||
show a colored letter icon with deterministic hue based on title.
|
||||
Eliminates privacy leak and Brave Shields compatibility issues."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: backdrop-filter Fallback for Brave Shields
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/css/main.css` (add `--bg-solid-fallback` per theme + `@supports not` block)
|
||||
|
||||
- [ ] **Step 1: Add --bg-solid-fallback to each theme**
|
||||
|
||||
Add the variable to each theme's `[data-theme]` block. The value is an opaque version of `--bg-board`:
|
||||
|
||||
| Theme | Line | `--bg-solid-fallback` value |
|
||||
|---|---|---|
|
||||
| nebula | ~82 | `#0a060e` |
|
||||
| crescent | ~108 | `#0c0b08` |
|
||||
| event-horizon | ~137 | `#06040f` |
|
||||
| merchantman | ~163 | `#040d0d` |
|
||||
| julia-jin | ~189 | `#080c12` |
|
||||
| sc-sunset | ~216 | `#0e0808` |
|
||||
| hellion-hud | ~245 | `#04080c` |
|
||||
| hellion-energy | ~278 | `#040a08` |
|
||||
| satisfactory | ~310 | `#060a0c` |
|
||||
| avorion | ~341 | `#040c0a` |
|
||||
| hellion-stealth | ~371 | `#060a0e` |
|
||||
|
||||
Add `--bg-solid-fallback: <value>;` as the last variable in each theme block.
|
||||
|
||||
- [ ] **Step 2: Add @supports not block at the end of the general layout section**
|
||||
|
||||
Add after the existing board/widget styles, before the theme-specific sections (around line 75, before the first `[data-theme]` block):
|
||||
|
||||
```css
|
||||
/* Fallback fuer Browser die backdrop-filter blockieren (z.B. Brave Shields) */
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
.board,
|
||||
.widget,
|
||||
.settings-panel,
|
||||
.dialog-box,
|
||||
.theme-modal,
|
||||
.search-bar {
|
||||
background-color: var(--bg-solid-fallback, var(--bg-primary));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify fallback works**
|
||||
|
||||
Test in Brave with Shields set to aggressive. Or test by temporarily adding this CSS rule:
|
||||
```css
|
||||
.board { backdrop-filter: none !important; }
|
||||
```
|
||||
Verify that boards still have a visible background (opaque, not transparent).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/css/main.css
|
||||
git commit -m "fix(compat): add backdrop-filter fallback for Brave Shields
|
||||
|
||||
Add --bg-solid-fallback CSS variable to all 11 themes and a
|
||||
@supports not (backdrop-filter) block. UI remains usable when
|
||||
Brave Shields or strict fingerprinting settings block backdrop-filter."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Complete i18n Coverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `newtab.html:26-42` (add `data-i18n-title` to 5 header buttons)
|
||||
- Modify: `newtab.html:198, 215, 374` (add `data-i18n` to 3 setting buttons)
|
||||
- Modify: `src/js/i18n.js` (add 10 new keys — 8 from spec + 2 from Task 4)
|
||||
|
||||
- [ ] **Step 1: Add data-i18n-title to header buttons in newtab.html**
|
||||
|
||||
Line 26 — change:
|
||||
```html
|
||||
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)">
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)" data-i18n-title="header.import_title">
|
||||
```
|
||||
|
||||
Line 30 — change:
|
||||
```html
|
||||
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen">
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen" data-i18n-title="header.board_title">
|
||||
```
|
||||
|
||||
Line 34 — change:
|
||||
```html
|
||||
<button class="btn-icon" id="btnNote" title="Schnellnotiz">
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-icon" id="btnNote" title="Schnellnotiz" data-i18n-title="header.note_title">
|
||||
```
|
||||
|
||||
Line 38 — change:
|
||||
```html
|
||||
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme">
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme" data-i18n-title="header.theme_title">
|
||||
```
|
||||
|
||||
Line 42 — change:
|
||||
```html
|
||||
<button class="btn-icon" id="btnSettings" title="Einstellungen">
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-icon" id="btnSettings" title="Einstellungen" data-i18n-title="header.settings_title">
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add data-i18n to settings buttons in newtab.html**
|
||||
|
||||
Line 198 — change:
|
||||
```html
|
||||
<button class="btn-small" id="btnRestartOnboarding">Start</button>
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-small" id="btnRestartOnboarding" data-i18n="settings.onboarding_btn">Start</button>
|
||||
```
|
||||
|
||||
Line 215 — change:
|
||||
```html
|
||||
<button class="btn-danger" id="btnResetAll">Reset</button>
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-danger" id="btnResetAll" data-i18n="settings.reset_btn">Reset</button>
|
||||
```
|
||||
|
||||
Line 374 — change:
|
||||
```html
|
||||
<button class="btn-small" id="btnBgFile">Upload</button>
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-small" id="btnBgFile" data-i18n="settings.bg_upload_btn">Upload</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add new keys to STRINGS.de in i18n.js**
|
||||
|
||||
Add these keys to the `STRINGS.de` object, in the appropriate sections:
|
||||
|
||||
In the Header section:
|
||||
```javascript
|
||||
'header.import_title': 'Bookmarks importieren (HTML)',
|
||||
'header.board_title': 'Neues Board hinzufügen',
|
||||
'header.note_title': 'Schnellnotiz',
|
||||
'header.theme_title': 'Darstellung & Theme',
|
||||
'header.settings_title': 'Einstellungen',
|
||||
```
|
||||
|
||||
In the Settings section:
|
||||
```javascript
|
||||
'settings.onboarding_btn': 'Start',
|
||||
'settings.reset_btn': 'Reset',
|
||||
'settings.bg_upload_btn': 'Upload',
|
||||
'settings.bg_invalid_url': 'Nur lokale Bilder (Upload) sind als Hintergrund erlaubt.',
|
||||
'settings.bg_invalid_url.title': 'Ungültige URL',
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add new keys to STRINGS.en in i18n.js**
|
||||
|
||||
Add the matching English keys to `STRINGS.en`:
|
||||
|
||||
In the Header section:
|
||||
```javascript
|
||||
'header.import_title': 'Import bookmarks (HTML)',
|
||||
'header.board_title': 'Add new board',
|
||||
'header.note_title': 'Quick note',
|
||||
'header.theme_title': 'Appearance & Theme',
|
||||
'header.settings_title': 'Settings',
|
||||
```
|
||||
|
||||
In the Settings section:
|
||||
```javascript
|
||||
'settings.onboarding_btn': 'Start',
|
||||
'settings.reset_btn': 'Reset',
|
||||
'settings.bg_upload_btn': 'Upload',
|
||||
'settings.bg_invalid_url': 'Only local images (upload) are allowed as background.',
|
||||
'settings.bg_invalid_url.title': 'Invalid URL',
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify translations**
|
||||
|
||||
Reload extension. Test:
|
||||
1. Set language to English → hover over header buttons → verify English tooltips
|
||||
2. Set language to German → hover → verify German tooltips
|
||||
3. Open Settings → verify "Start", "Reset", "Upload" buttons have `data-i18n` attributes (inspect in DevTools)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add newtab.html src/js/i18n.js
|
||||
git commit -m "fix(i18n): complete missing translations for toolbar tooltips and button texts
|
||||
|
||||
Add data-i18n-title to 5 header buttons, data-i18n to 3 settings buttons.
|
||||
Add 10 new keys to STRINGS.de and STRINGS.en including background URL
|
||||
validation error messages."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Version Bump, Changelog, Clock Cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/app.js:135`
|
||||
- Modify: `manifest.json:5`
|
||||
- Modify: `manifest.firefox.json` (version field)
|
||||
- Modify: `manifest.opera.json` (version field)
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1: Store clock interval ID in app.js**
|
||||
|
||||
Replace line 135 in `src/js/app.js`:
|
||||
|
||||
```javascript
|
||||
setInterval(tick, 1000);
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
const clockInterval = setInterval(tick, 1000);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Bump version in all three manifests**
|
||||
|
||||
In `manifest.json`, `manifest.firefox.json`, and `manifest.opera.json`, change:
|
||||
```json
|
||||
"version": "2.0.0",
|
||||
```
|
||||
To:
|
||||
```json
|
||||
"version": "2.0.1",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add CHANGELOG entry**
|
||||
|
||||
Add this block at the top of `CHANGELOG.md`, after the header and before the v2.0.0 entry:
|
||||
|
||||
```markdown
|
||||
### v2.0.1 — 16.04.2026
|
||||
|
||||
#### Security
|
||||
|
||||
- **Background URL validation** — Only `blob:` and `data:image/` protocols allowed in CSS `backgroundImage` (prevents CSS injection via manipulated storage)
|
||||
- **Import URL validation** — `javascript:`, `data:`, and other unsafe protocols are blocked during JSON import
|
||||
- **Immutable import mapping** — Imported boards, bookmarks, and notes are sanitized with explicit field selection and string length limits
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **Widget minimize race condition** — Replaced `setTimeout` with `transitionend` event; `openWidget()` during animation no longer causes display glitch
|
||||
- **Notes import mutation** — Import now uses `Notes.init()` instead of directly setting `Notes._notes`
|
||||
- **Complete i18n coverage** — 5 header button tooltips and 3 settings button texts now have `data-i18n` attributes (10 new translation keys)
|
||||
|
||||
#### Changed
|
||||
|
||||
- **Widget event system** — `WidgetManager` now dispatches `widget:close`, `widget:minimize`, `widget:open` CustomEvents via `EventTarget`. Calculator, Timer, and ImageRef use `WidgetManager.on()` instead of monkey-patching
|
||||
- **Local favicon icons** — Replaced Google Favicons API with local colored letter icons (deterministic hue per title). Zero external network requests, Brave Shields compatible
|
||||
- **backdrop-filter fallback** — `@supports not (backdrop-filter)` block with `--bg-solid-fallback` per theme for Brave Shields compatibility
|
||||
- **Clock interval cleanup** — `setInterval` ID stored in variable
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify everything**
|
||||
|
||||
Full manual test:
|
||||
1. Reload extension
|
||||
2. Verify version in `chrome://extensions` shows 2.0.1
|
||||
3. Open/close/minimize/reopen widgets of all types
|
||||
4. Switch language DE/EN — all tooltips translate
|
||||
5. Import/export JSON data
|
||||
6. Upload background image
|
||||
7. Check Network tab — zero external requests
|
||||
8. Check Console — zero errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/app.js manifest.json manifest.firefox.json manifest.opera.json CHANGELOG.md
|
||||
git commit -m "chore(release): bump version to v2.0.1 — hardening release
|
||||
|
||||
Security fixes, widget event system, local favicons, i18n completeness,
|
||||
backdrop-filter fallback, code quality improvements. See CHANGELOG.md."
|
||||
```
|
||||
@@ -1,581 +0,0 @@
|
||||
# Hellion NewTab — Calculator Upgrade Design
|
||||
|
||||
**Datum:** 2026-04-16
|
||||
**Autor:** Florian Wathling / Claude Code
|
||||
**Status:** Approved
|
||||
**Scope:** Calculator erweitern um Scientific, Unit-Converter und Game-Rechner (Satisfactory, Factorio, Stationeers)
|
||||
**Ziel-Version:** v2.1.0
|
||||
|
||||
---
|
||||
|
||||
## Kontext
|
||||
|
||||
Der Calculator ist aktuell ein reiner Grundrechenarten-Taschenrechner (720 Zeilen, Shunting-Yard Parser, 4x5 Button-Grid, History). Das Upgrade macht ihn zum zentralen Tool-Widget mit 6 Modi:
|
||||
|
||||
1. **Standard** (bestehend)
|
||||
2. **Scientific** (Wurzel, Potenz, Pi, Formel-Helfer)
|
||||
3. **Unit-Converter** (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche)
|
||||
4. **Satisfactory** (Items/Min, Overclock-Power, Maschinen-Rechner)
|
||||
5. **Factorio** (Assembler-Ratios, Belt-Throughput, Maschinen-Rechner)
|
||||
6. **Stationeers** (Idealgas, Furnace/Verbrennung, Solar/Batterie, Atmosphäre)
|
||||
|
||||
---
|
||||
|
||||
## Sektion 1: Architektur und Dateistruktur
|
||||
|
||||
### Datei-Aufteilung
|
||||
|
||||
```
|
||||
src/js/
|
||||
├── calculator.js # Core: Tab-System, Standard-Modus, erweiterter Shunting-Yard Parser
|
||||
├── calc-scientific.js # Scientific-Modus
|
||||
├── calc-converter.js # Unit-Converter
|
||||
├── calc-satisfactory.js # Satisfactory Calculator
|
||||
├── calc-factorio.js # Factorio Calculator
|
||||
└── calc-stationeers.js # Stationeers Calculator
|
||||
```
|
||||
|
||||
### Load-Order in newtab.html
|
||||
|
||||
```
|
||||
... → widgets.js → notes.js → calculator.js → calc-scientific.js → calc-converter.js →
|
||||
calc-satisfactory.js → calc-factorio.js → calc-stationeers.js → timer.js → ...
|
||||
```
|
||||
|
||||
Alle Mode-Dateien laden nach `calculator.js` und vor `timer.js`. Kein zirkulärer Dependency-Konflikt.
|
||||
|
||||
### Registrierungs-Pattern
|
||||
|
||||
Jede Mode-Datei registriert sich beim Calculator-Objekt:
|
||||
|
||||
```javascript
|
||||
Calculator.registerMode('scientific', {
|
||||
label: '\uD83D\uDCD0', // Icon
|
||||
shortName: 'Sci', // Tab-Label (3 Zeichen)
|
||||
titleKey: 'calculator.tab.scientific', // i18n-Key
|
||||
render(bodyEl) { /* UI aufbauen */ },
|
||||
destroy() { /* Cleanup, Event-Listener entfernen */ }
|
||||
});
|
||||
```
|
||||
|
||||
`calculator.js` bekommt:
|
||||
|
||||
```javascript
|
||||
_modes: new Map(),
|
||||
_activeMode: 'standard',
|
||||
|
||||
registerMode(name, config) {
|
||||
this._modes.set(name, config);
|
||||
},
|
||||
```
|
||||
|
||||
Die Tab-Leiste wird dynamisch aus `_modes` gebaut. Standard-Modus ist immer registriert (intern, nicht per externer Datei). Die anderen Modi kommen dazu wenn ihre Script-Datei geladen ist.
|
||||
|
||||
### Tab-Wechsel
|
||||
|
||||
```javascript
|
||||
switchMode(name) {
|
||||
const mode = this._modes.get(name);
|
||||
if (!mode) return;
|
||||
this._activeMode = name;
|
||||
const body = WidgetManager.getBody(this.WIDGET_ID);
|
||||
if (!body) return;
|
||||
|
||||
// Alten Modus aufräumen
|
||||
const oldMode = this._modes.get(this._previousMode);
|
||||
if (oldMode && oldMode.destroy) oldMode.destroy();
|
||||
|
||||
// Neuen Modus rendern
|
||||
body.textContent = '';
|
||||
mode.render(body);
|
||||
|
||||
// Tab-UI aktualisieren
|
||||
this._updateTabBar();
|
||||
|
||||
// State speichern
|
||||
this.save();
|
||||
}
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
Jeder Modus speichert seinen State als Sub-Key unter `calculator` im bestehenden `widgetStates`:
|
||||
|
||||
```javascript
|
||||
{
|
||||
calculator: {
|
||||
x: 400, y: 120, width: 320, height: 480,
|
||||
open: true,
|
||||
activeMode: 'standard',
|
||||
history: [{ expr: '42 × 7', result: '294' }],
|
||||
converter: { lastCategory: 'length', fromUnit: 'cm', toUnit: 'in' },
|
||||
satisfactory: { lastSubMode: 'itemsPerMin' },
|
||||
factorio: { lastSubMode: 'ratio', lastAssembler: 'asm3' },
|
||||
stationeers: { lastSubMode: 'gas' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Read-before-write Pattern bleibt: `const data = await Store.get(this.STORAGE_KEY) || {};`
|
||||
|
||||
---
|
||||
|
||||
## Sektion 2: Standard-Modus (Änderungen)
|
||||
|
||||
### Parser-Erweiterung
|
||||
|
||||
Der Shunting-Yard Parser wird um zwei Operationen erweitert:
|
||||
|
||||
**Potenz-Operator `^`:**
|
||||
- Binärer Operator mit höchster Precedence (über `*` und `/`)
|
||||
- Rechts-assoziativ: `2^3^2` = `2^(3^2)` = 512
|
||||
- Tokenizer erkennt `^` als `{ type: 'op', value: '^' }`
|
||||
- parseFactor() → parsePower() → parseFactor() (neue Precedence-Stufe)
|
||||
|
||||
**Wurzel-Funktion `sqrt`:**
|
||||
- Wird vom Scientific-Modus als `sqrt(` in die Expression eingefügt
|
||||
- Tokenizer erkennt `sqrt` als `{ type: 'func', value: 'sqrt' }`
|
||||
- parseFactor() prüft auf Functions vor Numbers
|
||||
|
||||
Die bestehende Operator-Hierarchie wird:
|
||||
```
|
||||
parseExpr: + -
|
||||
parseTerm: * / %
|
||||
parsePower: ^ ← NEU
|
||||
parseFactor: number | (expr) | func(expr) ← func NEU
|
||||
```
|
||||
|
||||
### Keine Änderungen am Standard-UI
|
||||
|
||||
Das 4x5 Button-Grid, History-Panel und Keyboard-Support bleiben identisch. Die Parser-Erweiterung ist rückwärtskompatibel (keine bestehende Expression bricht).
|
||||
|
||||
---
|
||||
|
||||
## Sektion 3: Scientific-Modus
|
||||
|
||||
### Zusätzliche Buttons
|
||||
|
||||
2 neue Reihen über dem Standard-Grid:
|
||||
|
||||
| Button | Wert | Aktion |
|
||||
|---|---|---|
|
||||
| √ | `sqrt(` | Unäre Funktion, öffnet Klammer |
|
||||
| x² | `^2` | Hängt `^2` an Expression |
|
||||
| xⁿ | `^` | Fügt Potenz-Operator ein |
|
||||
| π | `3.14159265359` | Konstante einfügen |
|
||||
| e | `2.71828182846` | Konstante einfügen |
|
||||
| ± | toggle | Vorzeichen des letzten Werts wechseln |
|
||||
|
||||
Darunter das Standard 4x5-Grid (C, Klammern, %, ÷, 0-9, Operatoren, =). Der Scientific-Modus nutzt den gleichen `_handleKey()`/`_calculate()`-Flow.
|
||||
|
||||
### Formel-Helfer
|
||||
|
||||
Ein Dropdown unter dem Button-Grid mit vorgefertigten Formeln:
|
||||
|
||||
| Formel | Eingabefelder | Berechnung |
|
||||
|---|---|---|
|
||||
| Kreis-Fläche | Radius (r) | `π × r²` |
|
||||
| Kreis-Umfang | Radius (r) | `2 × π × r` |
|
||||
| °C → °F | Temperatur | `(C × 9/5) + 32` |
|
||||
| °F → °C | Temperatur | `(F - 32) × 5/9` |
|
||||
| Pythagoras | a, b | `√(a² + b²)` |
|
||||
| Prozent-Wert | Wert, Prozent | `Wert × Prozent / 100` |
|
||||
|
||||
Jede Formel öffnet inline Eingabefelder + Live-Ergebnis. Nutzt `_formatResult()` für einheitliche Zahlenformatierung.
|
||||
|
||||
### Keyboard
|
||||
|
||||
Gleicher Keyboard-Support wie Standard-Modus, plus:
|
||||
- `p` → Pi einfügen
|
||||
- `e` → Euler einfügen (kein Konflikt: `e` ist im Standard nicht belegt, nur `c`/`C` und `Escape` sind Clear)
|
||||
|
||||
---
|
||||
|
||||
## Sektion 4: Unit-Converter
|
||||
|
||||
### UI-Aufbau
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ [Kategorie-Dropdown ▼]│
|
||||
│ │
|
||||
│ [123.45 ] [cm ▼] │
|
||||
│ ⇅ (Swap-Button) │
|
||||
│ [48.622 ] [in ▼] │
|
||||
│ │
|
||||
│ Schnellreferenz: │
|
||||
│ 1 cm = 0.3937 in │
|
||||
│ 1 in = 2.54 cm │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### Kategorien und Einheiten
|
||||
|
||||
| Kategorie | Einheiten | Basis-Einheit |
|
||||
|---|---|---|
|
||||
| Länge | mm, cm, m, km, in, ft, yd, mi | m |
|
||||
| Gewicht | mg, g, kg, t, oz, lb | g |
|
||||
| Temperatur | °C, °F, K | (Spezialfunktionen) |
|
||||
| Volumen | ml, L, m³, gal(US), gal(UK), ft³ | ml |
|
||||
| Geschwindigkeit | m/s, km/h, mph, kn | m/s |
|
||||
| Fläche | mm², cm², m², km², ha, acre, ft², in² | m² |
|
||||
|
||||
### Konvertierungs-Logik
|
||||
|
||||
Jede Einheit hat `toBase(value)` und `fromBase(value)`:
|
||||
|
||||
```javascript
|
||||
const LENGTH_UNITS = {
|
||||
mm: { toBase: v => v / 1000, fromBase: v => v * 1000 },
|
||||
cm: { toBase: v => v / 100, fromBase: v => v * 100 },
|
||||
m: { toBase: v => v, fromBase: v => v },
|
||||
km: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||
in: { toBase: v => v * 0.0254, fromBase: v => v / 0.0254 },
|
||||
ft: { toBase: v => v * 0.3048, fromBase: v => v / 0.3048 },
|
||||
yd: { toBase: v => v * 0.9144, fromBase: v => v / 0.9144 },
|
||||
mi: { toBase: v => v * 1609.344, fromBase: v => v / 1609.344 }
|
||||
};
|
||||
```
|
||||
|
||||
Temperatur bekommt eigene Funktionen (nicht linear):
|
||||
|
||||
```javascript
|
||||
const TEMP_CONVERSIONS = {
|
||||
'C_F': v => (v * 9/5) + 32,
|
||||
'C_K': v => v + 273.15,
|
||||
'F_C': v => (v - 32) * 5/9,
|
||||
'F_K': v => (v - 32) * 5/9 + 273.15,
|
||||
'K_C': v => v - 273.15,
|
||||
'K_F': v => (v - 273.15) * 9/5 + 32
|
||||
};
|
||||
```
|
||||
|
||||
### Verhalten
|
||||
|
||||
- Live-Update bei Eingabe (kein "Berechnen"-Button)
|
||||
- Swap-Button (⇅) tauscht Quell- und Ziel-Einheit
|
||||
- Schnellreferenz zeigt `1 [from] = x [to]` und umgekehrt
|
||||
- Kein Keyboard-Override (native `<input>` Felder)
|
||||
|
||||
### Storage
|
||||
|
||||
```javascript
|
||||
converter: { lastCategory: 'length', fromUnit: 'cm', toUnit: 'in' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sektion 5: Satisfactory Calculator
|
||||
|
||||
### Sub-Modi
|
||||
|
||||
Drei Buttons oben wählen den aktiven Rechner:
|
||||
|
||||
#### 5a: Items/Min
|
||||
|
||||
**Eingabefelder:**
|
||||
- Items per Craft (default: 1)
|
||||
- Craft Time in Sekunden (default: 4)
|
||||
- Clock Speed in % (default: 100)
|
||||
|
||||
**Formel:**
|
||||
```
|
||||
Output = (ItemsPerCraft × 60) / CraftTime × (ClockSpeed / 100)
|
||||
```
|
||||
|
||||
**Ausgabe:** `X.XX items/min`
|
||||
|
||||
#### 5b: Overclock Power
|
||||
|
||||
**Eingabefelder:**
|
||||
- Base Power in MW (default: 30)
|
||||
- Clock Speed in % (default: 100)
|
||||
|
||||
**Formeln:**
|
||||
```
|
||||
PowerUsage = BasePower × (ClockSpeed / 100) ^ 1.321928
|
||||
EnergyPerItem = (ClockSpeed / 100) ^ 0.321928
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Power Usage: X.X MW`
|
||||
- `Efficiency: ↓ X.X% per item` (nur bei ClockSpeed > 100)
|
||||
|
||||
#### 5c: Maschinen
|
||||
|
||||
**Eingabefelder:**
|
||||
- Target Output/Min (default: 60)
|
||||
- Items per Craft (default: 1)
|
||||
- Craft Time in Sekunden (default: 4)
|
||||
- Clock Speed in % (default: 100)
|
||||
- Base Power in MW (default: 30)
|
||||
|
||||
**Formeln:**
|
||||
```
|
||||
ItemsPerMin = (ItemsPerCraft × 60) / CraftTime × (ClockSpeed / 100)
|
||||
Machines = ceil(TargetOutput / ItemsPerMin)
|
||||
TotalPower = Machines × BasePower × (ClockSpeed / 100) ^ 1.321928
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Machines needed: X`
|
||||
- `Total Power: X.X MW`
|
||||
|
||||
### Verhalten
|
||||
|
||||
Alle Felder berechnen live. `<input type="number">` mit `step`-Attribut für sinnvolle Schrittweiten.
|
||||
|
||||
---
|
||||
|
||||
## Sektion 6: Factorio Calculator
|
||||
|
||||
### Sub-Modi
|
||||
|
||||
#### 6a: Assembler-Ratio
|
||||
|
||||
**Eingabefelder:**
|
||||
- Assembler-Dropdown: Assembler 1 (0.5), Assembler 2 (0.75), Assembler 3 (1.25)
|
||||
- Recipe Output Count (default: 1)
|
||||
- Recipe Time in Sekunden (default: 1)
|
||||
|
||||
**Formel:**
|
||||
```
|
||||
OutputPerSecond = RecipeOutput × CraftingSpeed / RecipeTime
|
||||
OutputPerMinute = OutputPerSecond × 60
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `X.XX items/s`
|
||||
- `X.XX items/min`
|
||||
|
||||
#### 6b: Belt-Throughput
|
||||
|
||||
**Eingabefelder:**
|
||||
- Belt-Dropdown: Yellow (15/s), Red (30/s), Blue (45/s)
|
||||
- Items consumed per second per machine (default: 1)
|
||||
|
||||
**Feste Werte:**
|
||||
|
||||
| Belt | Total (items/s) | Per Side (items/s) |
|
||||
|---|---|---|
|
||||
| Yellow | 15 | 7.5 |
|
||||
| Red | 30 | 15 |
|
||||
| Blue | 45 | 22.5 |
|
||||
|
||||
**Formel:**
|
||||
```
|
||||
MachinesPerBelt = floor(BeltThroughput / ItemsConsumedPerSec)
|
||||
Utilization = (ItemsConsumedPerSec × MachinesPerBelt) / BeltThroughput × 100
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Machines per belt: X`
|
||||
- `Belt utilization: X%`
|
||||
|
||||
#### 6c: Maschinen
|
||||
|
||||
**Eingabefelder:**
|
||||
- Assembler-Dropdown
|
||||
- Target Output/s (default: 10)
|
||||
- Recipe Output Count (default: 1)
|
||||
- Recipe Time in Sekunden (default: 1)
|
||||
|
||||
**Formel:**
|
||||
```
|
||||
OutputPerMachine = RecipeOutput × CraftingSpeed / RecipeTime
|
||||
Machines = ceil(TargetOutput / OutputPerMachine)
|
||||
TotalThroughput = Machines × OutputPerMachine
|
||||
BeltNeeded = kleinster Belt der TotalThroughput schafft
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Machines needed: X`
|
||||
- `Belt needed: [Color] (X% utilization)`
|
||||
|
||||
---
|
||||
|
||||
## Sektion 7: Stationeers Calculator
|
||||
|
||||
### Sub-Modi
|
||||
|
||||
Vier Buttons oben (statt drei wie bei den anderen Game-Rechnern).
|
||||
|
||||
#### 7a: Gas (Idealgas PV=nRT)
|
||||
|
||||
**Eingabefelder:**
|
||||
- Dropdown: Gesucht = P, V, n oder T
|
||||
- Die drei anderen Variablen als Eingabefelder
|
||||
|
||||
**Konstante:** R = 8314.46261815324 (Stationeers-spezifisch, Einheit: L·Pa / mol·K)
|
||||
|
||||
**Formeln:**
|
||||
```
|
||||
P = nRT / V
|
||||
V = nRT / P
|
||||
n = PV / RT
|
||||
T = PV / nR
|
||||
```
|
||||
|
||||
**Eingabe-Einheiten:**
|
||||
- P in kPa (wird intern × 1000 zu Pa)
|
||||
- V in Litern
|
||||
- T in Kelvin (Hilfstext zeigt °C-Äquivalent)
|
||||
- n in mol
|
||||
|
||||
#### 7b: Furnace / Verbrennung
|
||||
|
||||
**Eingabefelder:**
|
||||
- Fuel Ratio (0 bis 1, Anteil Brennstoff am Gesamtgas)
|
||||
- Start-Temperatur in Kelvin
|
||||
- Start-Druck in kPa
|
||||
|
||||
**Formeln:**
|
||||
```
|
||||
T_nach = (T_vor × specificHeat + fuel × 563452) / (specificHeat + fuel × 172.615)
|
||||
P_nach = P_vor × T_nach × (1 + 5.7 × fuel) / T_vor
|
||||
```
|
||||
|
||||
Wobei:
|
||||
- `fuel = min(ratioO2, ratioVolatile / 2)`
|
||||
- `specificHeat` = gewichtete Summe der Gas-Wärmekapazitäten
|
||||
- Vereinfachung: Fuel Ratio als einzelner Wert (0-1), `specificHeat(before)` wird aus reinem Fuel berechnet (61.9 J/mol·K für 1:2 O₂:H₂ Mischung)
|
||||
- 563452 = Energie pro Mol bei 95% Effizienz
|
||||
- 172.615 = 0.95 × (243.6 - 61.9)
|
||||
|
||||
**Validierung:**
|
||||
- Warnung wenn Fuel < 0.05 (unter 5% Minimum)
|
||||
- Warnung wenn Start-Druck < 10 kPa
|
||||
|
||||
**Ausgabe:**
|
||||
- `T after ignition: X K (X °C)`
|
||||
- `P after ignition: X kPa`
|
||||
|
||||
#### 7c: Solar / Batterie
|
||||
|
||||
**Eingabefelder:**
|
||||
- Anzahl Panels (default: 12)
|
||||
- Watt pro Panel (default: 500, Mond-Wert)
|
||||
- Tag-Länge in Sekunden (default: 600)
|
||||
- Nacht-Länge in Sekunden (default: 600)
|
||||
- Verbrauch in Watt (default: 2000)
|
||||
|
||||
**Formeln:**
|
||||
```
|
||||
Generation = Panels × WattsPerPanel
|
||||
Surplus = Generation - Consumption
|
||||
NightEnergy = Consumption × NightLength (in Watt-Sekunden)
|
||||
BatteriesNeeded = ceil(NightEnergy / 50000) (Station Battery = 50.000 Ws)
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Generation: X W`
|
||||
- `Surplus: X W` (rot wenn negativ)
|
||||
- `Night Energy: X Ws`
|
||||
- `Batteries needed: X`
|
||||
|
||||
#### 7d: Atmosphäre / Gas-Mischer
|
||||
|
||||
**Eingabefelder:**
|
||||
- Target Temperatur in Kelvin
|
||||
- Gas 1 Temperatur in Kelvin
|
||||
- Gas 2 Temperatur in Kelvin
|
||||
|
||||
**Formel:**
|
||||
```
|
||||
M1 = |T2 - T0| / (|T1 - T0| + |T2 - T0|)
|
||||
M2 = 1 - M1
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Mixer Input 1: X.X%`
|
||||
- `Mixer Input 2: X.X%`
|
||||
|
||||
**Aufklappbare Wärmekapazität-Referenz:**
|
||||
|
||||
| Gas | Cp (J/mol·K) |
|
||||
|---|---|
|
||||
| O₂ | 21.1 |
|
||||
| H₂ | 20.4 |
|
||||
| CO₂ | 28.2 |
|
||||
| N₂ | 20.6 |
|
||||
| H₂O | 72.0 |
|
||||
| N₂O | 23.0 |
|
||||
| Pollutant | 24.8 |
|
||||
|
||||
---
|
||||
|
||||
## Sektion 8: UI, i18n und Widget-Sizing
|
||||
|
||||
### Tab-Leiste
|
||||
|
||||
Horizontale Leiste direkt unter dem Widget-Header. Immer sichtbar (kein Scrollen).
|
||||
|
||||
| Tab | Icon | Label |
|
||||
|---|---|---|
|
||||
| Standard | 🔢 | Std |
|
||||
| Scientific | 📐 | Sci |
|
||||
| Converter | ⚖️ | Unit |
|
||||
| Satisfactory | ⚙️ | SAT |
|
||||
| Factorio | 🏭 | FAC |
|
||||
| Stationeers | 🚀 | STA |
|
||||
|
||||
Aktiver Tab: `border-bottom: 2px solid var(--accent)`, Text in `var(--accent)`.
|
||||
Inaktive Tabs: `color: rgba(255,255,255,0.5)`.
|
||||
CSS-Klasse: `.calc-tab-bar` und `.calc-tab`.
|
||||
|
||||
### Widget-Sizing
|
||||
|
||||
- Standard-Modus Minimum: 280 × 400 px
|
||||
- Komplexe Modi (Scientific, Game-Rechner): Auto-Resize auf 320 × 480 px (falls aktuell kleiner)
|
||||
- User-Resize überschreibt Auto-Resize
|
||||
- Widget-System-Minimum bleibt 200 × 150 px
|
||||
|
||||
### i18n
|
||||
|
||||
Geschätzt ~100 neue Keys in `STRINGS.de` und `STRINGS.en`:
|
||||
|
||||
- 6 Tab-Labels
|
||||
- 6 Kategorie-Namen (Converter)
|
||||
- ~48 Einheiten-Langformen (Converter)
|
||||
- ~30 Feld-Labels (Game-Rechner)
|
||||
- ~10 Ergebnis-Labels
|
||||
|
||||
Einheiten-Abkürzungen (cm, kg, °C, kPa) werden nicht übersetzt.
|
||||
|
||||
### Keyboard
|
||||
|
||||
- Standard-Modus: Bestehender Keyboard-Support (0-9, +, -, *, /, Enter, Backspace, Escape)
|
||||
- Scientific-Modus: Gleicher Support + `p` (Pi), `^` (Potenz)
|
||||
- Converter und Game-Modi: Kein Custom-Keyboard (native `<input>` Felder)
|
||||
|
||||
---
|
||||
|
||||
## Betroffene Dateien (Gesamt)
|
||||
|
||||
| Datei | Änderung |
|
||||
|---|---|
|
||||
| `src/js/calculator.js` | Tab-System, registerMode(), switchMode(), Parser-Erweiterung (^, sqrt) |
|
||||
| `src/js/calc-scientific.js` | NEU: Scientific-Modus |
|
||||
| `src/js/calc-converter.js` | NEU: Unit-Converter |
|
||||
| `src/js/calc-satisfactory.js` | NEU: Satisfactory Calculator |
|
||||
| `src/js/calc-factorio.js` | NEU: Factorio Calculator |
|
||||
| `src/js/calc-stationeers.js` | NEU: Stationeers Calculator |
|
||||
| `src/css/main.css` | Tab-Bar Styles, Mode-spezifische Styles |
|
||||
| `src/js/i18n.js` | ~100 neue Keys (DE + EN) |
|
||||
| `newtab.html` | 5 neue `<script>` Tags in Load-Order |
|
||||
| `manifest.json` | Version → 2.1.0 |
|
||||
| `manifest.firefox.json` | Version → 2.1.0 |
|
||||
| `manifest.opera.json` | Version → 2.1.0 |
|
||||
| `CHANGELOG.md` | v2.1.0 Eintrag |
|
||||
|
||||
## Implementierungsreihenfolge
|
||||
|
||||
1. **Calculator Core** — Tab-System, registerMode(), switchMode(), Tab-Bar CSS
|
||||
2. **Parser-Erweiterung** — `^` Operator und `sqrt` Funktion
|
||||
3. **Scientific-Modus** — Buttons, Formel-Helfer, Registrierung
|
||||
4. **Unit-Converter** — Kategorien, Einheiten, Konvertierungs-Logik, UI
|
||||
5. **Satisfactory Calculator** — 3 Sub-Modi, Formeln, UI
|
||||
6. **Factorio Calculator** — 3 Sub-Modi, Formeln, UI
|
||||
7. **Stationeers Calculator** — 4 Sub-Modi, Formeln, UI
|
||||
8. **i18n** — Alle neuen Keys (DE + EN)
|
||||
9. **Version Bump** — Manifests, CHANGELOG
|
||||
@@ -1,400 +0,0 @@
|
||||
# Hellion NewTab v2.0.1 — Hardening Release Design
|
||||
|
||||
**Datum:** 2026-04-16
|
||||
**Autor:** Florian Wathling / Claude Code
|
||||
**Status:** Approved
|
||||
**Scope:** Security, Stability, i18n, Code Quality
|
||||
**Strategie:** Foundation First (Event-System zuerst, dann darauf aufbauen)
|
||||
|
||||
---
|
||||
|
||||
## Kontext
|
||||
|
||||
Umfassender Audit von v2.0.0 hat Findings in vier Kategorien ergeben:
|
||||
- 3 Sicherheitslücken (HOCH)
|
||||
- 2 Stabilitätsprobleme (Race Conditions)
|
||||
- 8 fehlende i18n-Attribute
|
||||
- 3 Code-Qualität-Items
|
||||
|
||||
Dieses Design beschreibt alle Fixes als zusammenhängendes Hardening-Release.
|
||||
|
||||
---
|
||||
|
||||
## Sektion 1: Widget Event-System
|
||||
|
||||
### Problem
|
||||
|
||||
Calculator (`calculator.js:692-728`), Timer (`timer.js:723-758`) und ImageRef (`image-ref.js:463-498`) überschreiben `WidgetManager.close`, `.minimize` und `.openWidget` durch Monkey-Patching in ihrer `init()`. Das erzeugt eine 3-stufige Closure-Kette pro Methode. Funktional korrekt, aber fragil und schwer debugbar.
|
||||
|
||||
### Lösung
|
||||
|
||||
WidgetManager bekommt ein internes Event-System basierend auf `EventTarget`.
|
||||
|
||||
**Neue API in `widgets.js`:**
|
||||
|
||||
```javascript
|
||||
_emitter: new EventTarget(),
|
||||
|
||||
on(event, handler) {
|
||||
this._emitter.addEventListener(event, handler);
|
||||
},
|
||||
|
||||
off(event, handler) {
|
||||
this._emitter.removeEventListener(event, handler);
|
||||
},
|
||||
```
|
||||
|
||||
**Events:**
|
||||
|
||||
| Event | Feuert nach | Detail |
|
||||
|---|---|---|
|
||||
| `widget:close` | `entry.el.remove()` + `_widgets.delete(id)` | `{ id }` | **Achtung:** Element bereits entfernt, Listener dürfen nicht auf Widget-Entry zugreifen |
|
||||
| `widget:minimize` | State-Änderung + Animation + Save | `{ id }` |
|
||||
| `widget:open` | State-Änderung + Display-Reset + Save | `{ id }` |
|
||||
|
||||
**Migration der Widget-Module:**
|
||||
|
||||
Das gesamte Monkey-Patching wird ersetzt durch `WidgetManager.on()` Aufrufe:
|
||||
|
||||
```javascript
|
||||
// Beispiel: Calculator.init()
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) self.onClose();
|
||||
});
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = false;
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = true;
|
||||
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||
if (body && body.children.length === 0) self.renderBody(body);
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
ImageRef folgt dem gleichen Pattern, prüft aber per `self._images.some(img => img.id === id)` statt gegen eine feste WIDGET_ID.
|
||||
|
||||
**Load-Order:** Kein Problem. `widgets.js` wird vor allen Widget-Modulen geladen. Die Module rufen `WidgetManager.on()` in ihrer `init()` auf, die erst in `app.js` aufgerufen wird.
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `src/js/widgets.js` — Event-System hinzufügen, Events in close/minimize/openWidget dispatchen
|
||||
- `src/js/calculator.js` — Monkey-Patching (Z. 692-728) durch Event-Listener ersetzen
|
||||
- `src/js/timer.js` — Monkey-Patching (Z. 723-758) durch Event-Listener ersetzen
|
||||
- `src/js/image-ref.js` — Monkey-Patching (Z. 463-498) durch Event-Listener ersetzen
|
||||
|
||||
---
|
||||
|
||||
## Sektion 2: Minimize-Animation mit `transitionend`
|
||||
|
||||
### Problem
|
||||
|
||||
`WidgetManager.minimize()` (`widgets.js:154-163`) setzt `display: none` nach 250ms `setTimeout`. Wenn `openWidget()` in diesen 250ms aufgerufen wird, überschreibt der Timeout das `display: flex` wieder (Race Condition).
|
||||
|
||||
### Lösung
|
||||
|
||||
`setTimeout` wird durch `transitionend` Event ersetzt. Eine `_minimizing` Flag verhindert die Race Condition.
|
||||
|
||||
```javascript
|
||||
async minimize(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry.state.open = false;
|
||||
entry._minimizing = true;
|
||||
entry.el.classList.add('widget-minimized');
|
||||
|
||||
entry.el.addEventListener('transitionend', function onEnd(e) {
|
||||
if (e.target !== entry.el) return;
|
||||
entry.el.removeEventListener('transitionend', onEnd);
|
||||
if (entry._minimizing) {
|
||||
entry.el.style.display = 'none';
|
||||
}
|
||||
entry._minimizing = false;
|
||||
}, { once: false });
|
||||
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
|
||||
await this.save();
|
||||
},
|
||||
|
||||
async openWidget(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry._minimizing = false; // Race Condition verhindert
|
||||
entry.state.open = true;
|
||||
entry.el.style.display = 'flex';
|
||||
requestAnimationFrame(() => {
|
||||
entry.el.classList.remove('widget-minimized');
|
||||
});
|
||||
this.bringToFront(id);
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
|
||||
await this.save();
|
||||
},
|
||||
```
|
||||
|
||||
**Warum `_minimizing` Flag:** Robuster als `clearTimeout`, weil sie unabhängig von der CSS-Transition-Duration funktioniert.
|
||||
|
||||
**Fallback:** Falls `transitionend` nicht feuert (kein Transition definiert), bleibt das Widget sichtbar mit der Klasse. Akzeptabel, da alle Widgets in `main.css` eine Transition haben.
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `src/js/widgets.js` — `minimize()` und `openWidget()` umschreiben
|
||||
|
||||
---
|
||||
|
||||
## Sektion 3: Security Fixes
|
||||
|
||||
### 3a: URL-Injection in backgroundImage
|
||||
|
||||
**Datei:** `src/js/settings.js:93`
|
||||
**Problem:** `settings.bgUrl` wird unvalidiert in CSS-Template-Literal eingefügt.
|
||||
|
||||
**Fix:** Protokoll-Whitelist. Nur `blob:` und `data:image/` erlauben (die einzigen Protokolle die der Upload erzeugt).
|
||||
|
||||
```javascript
|
||||
function isValidBgUrl(url) {
|
||||
return typeof url === 'string' &&
|
||||
(url.startsWith('blob:') || url.startsWith('data:image/'));
|
||||
}
|
||||
```
|
||||
|
||||
Validierung an zwei Stellen: `applySettings()` und beim Speichern nach Upload.
|
||||
|
||||
### 3b: URL-Validierung beim JSON-Import
|
||||
|
||||
**Datei:** `src/js/data.js:45-49`
|
||||
**Problem:** Importierte Bookmark-URLs werden nicht auf Protokoll geprüft. `javascript:` oder `data:` URLs kommen durch.
|
||||
|
||||
**Fix:** Protokoll-Whitelist für importierte URLs.
|
||||
|
||||
```javascript
|
||||
function isSafeUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Integration in die Bookmark-Filter-Logik: `if (!bm || typeof bm.title !== 'string' || !isSafeUrl(bm.url)) return false;`
|
||||
|
||||
Ungültige Bookmarks werden still übersprungen.
|
||||
|
||||
### 3c: Objekt-Mutation im Import
|
||||
|
||||
**Datei:** `src/js/data.js:43-48`
|
||||
**Problem:** `b.id = b.id || uid()` mutiert das geparste JSON-Objekt direkt. Keine Längenvalidierung.
|
||||
|
||||
**Fix:** Immutable Mapping mit expliziter Feldauswahl und String-Längen-Limits.
|
||||
|
||||
```javascript
|
||||
.map(bm => ({
|
||||
id: bm.id || uid(),
|
||||
title: String(bm.title).slice(0, 200),
|
||||
url: bm.url,
|
||||
desc: String(bm.desc || '').slice(0, 500)
|
||||
}));
|
||||
```
|
||||
|
||||
Analog für Boards:
|
||||
|
||||
```javascript
|
||||
.map(b => ({
|
||||
id: b.id || uid(),
|
||||
title: String(b.title).slice(0, 100),
|
||||
blurred: !!b.blurred,
|
||||
bookmarks: /* bereits sanitized, siehe oben */
|
||||
}));
|
||||
```
|
||||
|
||||
Notes-Felder beim Import werden ebenfalls sanitized:
|
||||
|
||||
```javascript
|
||||
.filter(n => n && n.id && n.template)
|
||||
.map(n => ({
|
||||
id: n.id,
|
||||
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
|
||||
title: String(n.title || '').slice(0, 200),
|
||||
content: String(n.content || '').slice(0, 5000),
|
||||
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
|
||||
}));
|
||||
```
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `src/js/settings.js` — `isValidBgUrl()` + Validierung in `applySettings()`
|
||||
- `src/js/data.js` — `isSafeUrl()` + immutable Mapping + Längen-Limits
|
||||
|
||||
---
|
||||
|
||||
## Sektion 4: Lokale Favicons
|
||||
|
||||
### Problem
|
||||
|
||||
`getFaviconUrl()` (`state.js:36-43`) ruft Google Favicons API auf. Brave Shields blockiert das. Jeder Bookmark erzeugt einen fehlgeschlagenen Netzwerk-Request. Zusätzlich leakt jeder Hostname an Google.
|
||||
|
||||
### Lösung
|
||||
|
||||
Kein externer Request mehr. `getFaviconUrl()` wird entfernt. Bookmarks zeigen ein farbiges Buchstaben-Icon (erster Buchstabe des Titels).
|
||||
|
||||
**state.js:** `getFaviconUrl()` löschen.
|
||||
|
||||
**boards.js:** Statt `<img>` + Error-Fallback nur noch ein `<div>`:
|
||||
|
||||
```javascript
|
||||
const favicon = document.createElement('div');
|
||||
favicon.className = 'bm-favicon-local';
|
||||
favicon.textContent = bm.title.charAt(0).toUpperCase();
|
||||
// Deterministische Farbe pro Buchstabe
|
||||
const hue = (bm.title.charCodeAt(0) * 137) % 360;
|
||||
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
|
||||
```
|
||||
|
||||
Inline-Style für `backgroundColor` ist hier gerechtfertigt, weil der Wert dynamisch pro Bookmark berechnet wird. Restliche Styles (Größe, Border-Radius, Schrift) kommen aus CSS.
|
||||
|
||||
**main.css:** `.bm-favicon` und `.bm-favicon-fallback` ersetzen durch `.bm-favicon-local`.
|
||||
|
||||
### Was entfällt
|
||||
|
||||
- `getFaviconUrl()` in `state.js`
|
||||
- `<img class="bm-favicon">` Erzeugung in `boards.js`
|
||||
- Error-Listener für Favicon-Loads
|
||||
- `.bm-favicon` und `.bm-favicon-fallback` CSS-Regeln
|
||||
- Der einzige externe Netzwerk-Request der Extension
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `src/js/state.js` — `getFaviconUrl()` entfernen
|
||||
- `src/js/boards.js` — Favicon-Rendering umbauen
|
||||
- `src/css/main.css` — CSS-Klassen tauschen
|
||||
|
||||
---
|
||||
|
||||
## Sektion 5: i18n-Lücken schließen
|
||||
|
||||
### 5a: Toolbar-Buttons — fehlende `data-i18n-title`
|
||||
|
||||
Fünf Header-Buttons (`newtab.html:26-42`) haben hardcodierte deutsche `title`-Attribute.
|
||||
|
||||
| Button | Key | DE | EN |
|
||||
|---|---|---|---|
|
||||
| `#btnImport` | `header.import_title` | Bookmarks importieren (HTML) | Import bookmarks (HTML) |
|
||||
| `#btnAddBoard` | `header.board_title` | Neues Board hinzufügen | Add new board |
|
||||
| `#btnNote` | `header.note_title` | Schnellnotiz | Quick note |
|
||||
| `#btnTheme` | `header.theme_title` | Darstellung & Theme | Appearance & Theme |
|
||||
| `#btnSettings` | `header.settings_title` | Einstellungen | Settings |
|
||||
|
||||
**Fix:** `data-i18n-title` Attribute hinzufügen. `applyLanguage()` erkennt diese automatisch.
|
||||
|
||||
### 5b: Button-Texte ohne i18n
|
||||
|
||||
Drei Settings-Buttons haben hardcodierte Texte.
|
||||
|
||||
| Button | Key | DE | EN |
|
||||
|---|---|---|---|
|
||||
| `#btnRestartOnboarding` | `settings.onboarding_btn` | Start | Start |
|
||||
| `#btnResetAll` | `settings.reset_btn` | Reset | Reset |
|
||||
| `#btnBgFile` | `settings.bg_upload_btn` | Upload | Upload |
|
||||
|
||||
Aktuell in beiden Sprachen identisch, aber `data-i18n` wird für Konsistenz und zukünftige Erweiterbarkeit gesetzt.
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `newtab.html` — 5x `data-i18n-title`, 3x `data-i18n` hinzufügen
|
||||
- `src/js/i18n.js` — 8 neue Keys in `STRINGS.de` und `STRINGS.en`
|
||||
|
||||
---
|
||||
|
||||
## Sektion 6: Code-Qualität
|
||||
|
||||
### 6a: Notes-Mutation beim Import
|
||||
|
||||
**Datei:** `src/js/data.js:~79`
|
||||
**Problem:** `Notes._notes = merged` setzt das interne Array direkt, umgeht `Notes.save()`.
|
||||
|
||||
**Fix:** Nach dem Speichern in `widgetStates` wird `Notes.init()` aufgerufen statt das interne Array direkt zu manipulieren.
|
||||
|
||||
```javascript
|
||||
existingWidgets.notes = merged;
|
||||
await Store.set('widgetStates', existingWidgets);
|
||||
await Notes.init(); // Neu aus Storage laden + UI rendern
|
||||
```
|
||||
|
||||
### 6b: `backdrop-filter` Fallback
|
||||
|
||||
**Datei:** `src/css/main.css`
|
||||
**Problem:** 24 Stellen mit `backdrop-filter`. Brave Shields kann das blockieren.
|
||||
|
||||
**Fix:** Zentraler `@supports not` Block mit solidem Hintergrund-Fallback:
|
||||
|
||||
```css
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
.board,
|
||||
.widget,
|
||||
.settings-panel,
|
||||
.dialog-box,
|
||||
.theme-modal {
|
||||
background-color: var(--bg-solid-fallback);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Jedes Theme bekommt `--bg-solid-fallback` als deckende Variante der Glassmorphism-Farbe.
|
||||
|
||||
### 6c: Clock Interval Cleanup
|
||||
|
||||
**Datei:** `src/js/app.js:135`
|
||||
**Problem:** `setInterval(tick, 1000)` ID wird nicht gespeichert.
|
||||
|
||||
**Fix:** Interval-ID in Variable speichern. Niedrigste Priorität, da der Interval mit dem Tab stirbt.
|
||||
|
||||
```javascript
|
||||
let _clockInterval = null;
|
||||
_clockInterval = setInterval(tick, 1000);
|
||||
```
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `src/js/data.js` — Notes-Import über `Notes.init()` statt direkter Mutation
|
||||
- `src/css/main.css` — `@supports not` Block + `--bg-solid-fallback` pro Theme
|
||||
- `src/js/app.js` — Interval-ID speichern
|
||||
|
||||
---
|
||||
|
||||
## Implementierungsreihenfolge (Foundation First)
|
||||
|
||||
1. **Event-System** in `widgets.js` bauen
|
||||
2. **Widget-Module** auf Events migrieren (`calculator.js`, `timer.js`, `image-ref.js`)
|
||||
3. **Minimize mit `transitionend`** in `widgets.js`
|
||||
4. **Security Fixes** in `settings.js` und `data.js`
|
||||
5. **Lokale Favicons** in `state.js`, `boards.js`, `main.css`
|
||||
6. **i18n-Lücken** in `newtab.html` und `i18n.js`
|
||||
7. **Code-Qualität** in `data.js`, `main.css`, `app.js`
|
||||
8. **Version Bump** auf 2.0.1 in allen drei Manifests + CHANGELOG
|
||||
|
||||
## Betroffene Dateien (Gesamt)
|
||||
|
||||
| Datei | Sektionen |
|
||||
|---|---|
|
||||
| `src/js/widgets.js` | 1, 2 |
|
||||
| `src/js/calculator.js` | 1 |
|
||||
| `src/js/timer.js` | 1 |
|
||||
| `src/js/image-ref.js` | 1 |
|
||||
| `src/js/settings.js` | 3a |
|
||||
| `src/js/data.js` | 3b, 3c, 6a |
|
||||
| `src/js/state.js` | 4 |
|
||||
| `src/js/boards.js` | 4 |
|
||||
| `src/js/i18n.js` | 5 |
|
||||
| `src/js/app.js` | 6c |
|
||||
| `src/css/main.css` | 4, 6b |
|
||||
| `newtab.html` | 5 |
|
||||
| `manifest.json` | 8 |
|
||||
| `manifest.firefox.json` | 8 |
|
||||
| `manifest.opera.json` | 8 |
|
||||
| `CHANGELOG.md` | 8 |
|
||||
@@ -1,327 +0,0 @@
|
||||
# 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,9 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "__MSG_extName__",
|
||||
"default_locale": "en",
|
||||
"version": "2.1.0",
|
||||
"description": "__MSG_extDesc__",
|
||||
"name": "Hellion NewTab",
|
||||
"version": "1.5.2",
|
||||
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
|
||||
"author": "Hellion Online Media - Florian Wathling",
|
||||
"homepage_url": "https://hellion-media.de",
|
||||
|
||||
@@ -19,7 +18,6 @@
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "hellion-newtab@hellion-media.de",
|
||||
"update_url": "https://hellion-media.de/extensions/firefox/updates.json",
|
||||
"strict_min_version": "142.0",
|
||||
"data_collection_permissions": {
|
||||
"required": [
|
||||
@@ -34,9 +32,6 @@
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
},
|
||||
"icons": {
|
||||
"16": "assets/icons/icon16.png",
|
||||
"48": "assets/icons/icon48.png",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "__MSG_extName__",
|
||||
"default_locale": "en",
|
||||
"version": "2.1.0",
|
||||
"description": "__MSG_extDesc__",
|
||||
"name": "Hellion NewTab",
|
||||
"version": "1.5.2",
|
||||
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
|
||||
"author": "Hellion Online Media - Florian Wathling",
|
||||
"homepage_url": "https://hellion-media.de",
|
||||
"chrome_url_overrides": {
|
||||
@@ -19,9 +18,6 @@
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
},
|
||||
"icons": {
|
||||
"16": "assets/icons/icon16.png",
|
||||
"48": "assets/icons/icon48.png",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "__MSG_extName__",
|
||||
"default_locale": "en",
|
||||
"version": "2.1.0",
|
||||
"description": "__MSG_extDesc__",
|
||||
"name": "Hellion Dashboard (GX Native)",
|
||||
"version": "1.5.2",
|
||||
"description": "Ersetzt die Opera GX Startseite durch dein persönliches, leistungsoptimiertes Hellion Dashboard. Schnell, sauber und werbefrei.",
|
||||
"author": "Hellion Online Media - Florian Wathling",
|
||||
"homepage_url": "https://hellion-media.de",
|
||||
|
||||
@@ -40,9 +39,6 @@
|
||||
"default_title": "Hellion Dashboard"
|
||||
},
|
||||
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
},
|
||||
"icons": {
|
||||
"16": "assets/icons/icon16.png",
|
||||
"48": "assets/icons/icon48.png",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -23,25 +23,25 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)" data-i18n-title="header.import_title">
|
||||
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
<span data-i18n="header.import">Import</span>
|
||||
Import
|
||||
</button>
|
||||
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen" data-i18n-title="header.board_title">
|
||||
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<span data-i18n="header.board">Board</span>
|
||||
Board
|
||||
</button>
|
||||
<button class="btn-icon" id="btnNote" title="Schnellnotiz" data-i18n-title="header.note_title">
|
||||
<button class="btn-icon" id="btnNote" title="Schnellnotiz">
|
||||
<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>
|
||||
<span data-i18n="header.note">Note</span>
|
||||
Note
|
||||
</button>
|
||||
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme" data-i18n-title="header.theme_title">
|
||||
<button class="btn-icon" id="btnTheme" title="Theme wählen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 100 20 4 4 0 01-1-7.9 1 1 0 011-.1h1a2 2 0 002-2V7a5 5 0 00-3-4.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="6" r="1.5"/><circle cx="17" cy="10" r="1.5"/><circle cx="9" cy="17" r="1.5"/></svg>
|
||||
<span data-i18n="header.theme">Darstellung</span>
|
||||
Theme
|
||||
</button>
|
||||
<button class="btn-icon" id="btnSettings" title="Einstellungen" data-i18n-title="header.settings_title">
|
||||
<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>
|
||||
<span data-i18n="header.settings">Settings</span>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -49,11 +49,11 @@
|
||||
<!-- SEARCH BAR -->
|
||||
<div class="search-bar-wrapper" id="searchBarWrapper">
|
||||
<div class="search-bar">
|
||||
<button class="search-engine-toggle" id="searchEngineToggle" data-i18n-title="settings.search_engine_toggle" title="Suchmaschine wechseln">
|
||||
<button class="search-engine-toggle" id="searchEngineToggle" title="Suchmaschine wechseln">
|
||||
<span id="searchEngineIcon">G</span>
|
||||
</button>
|
||||
<input type="text" class="search-input" id="searchInput" data-i18n-placeholder="search.placeholder" placeholder="Search the web…" autocomplete="off" />
|
||||
<button class="search-submit" id="searchSubmit" data-i18n-title="search.submit_title">
|
||||
<input type="text" class="search-input" id="searchInput" placeholder="Search the web…" autocomplete="off" />
|
||||
<button class="search-submit" id="searchSubmit">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -61,22 +61,22 @@
|
||||
|
||||
<!-- WIDGET TOOLBAR -->
|
||||
<div class="widget-toolbar" id="widgetToolbar">
|
||||
<button class="widget-toolbar-btn" data-action="new-note" data-i18n-title="toolbar.note" title="Note erstellen">
|
||||
<button class="widget-toolbar-btn" data-action="new-note" title="Note erstellen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
</button>
|
||||
<button class="widget-toolbar-btn" data-action="new-checklist" data-i18n-title="toolbar.checklist" title="Checkliste erstellen">
|
||||
<button class="widget-toolbar-btn" data-action="new-checklist" title="Checkliste erstellen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
|
||||
</button>
|
||||
<button class="widget-toolbar-btn" data-action="calculator" data-i18n-title="toolbar.calculator" title="Taschenrechner">
|
||||
<button class="widget-toolbar-btn" data-action="calculator" title="Taschenrechner">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><circle cx="8" cy="10" r="0.5"/><circle cx="12" cy="10" r="0.5"/><circle cx="16" cy="10" r="0.5"/><circle cx="8" cy="14" r="0.5"/><circle cx="12" cy="14" r="0.5"/><circle cx="16" cy="14" r="0.5"/><circle cx="8" cy="18" r="0.5"/><circle cx="12" cy="18" r="0.5"/><circle cx="16" cy="18" r="0.5"/></svg>
|
||||
</button>
|
||||
<button class="widget-toolbar-btn" data-action="timer" data-i18n-title="toolbar.timer" title="Timer">
|
||||
<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" data-i18n-title="toolbar.imageref" title="Bild-Referenz">
|
||||
<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" data-i18n-title="toolbar.notebook" title="Alle Notes">
|
||||
<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>
|
||||
@@ -85,8 +85,8 @@
|
||||
<div class="notebook-overlay" id="notebookOverlay"></div>
|
||||
<aside class="notebook-panel" id="notebookPanel">
|
||||
<div class="notebook-header">
|
||||
<span class="notebook-header-title"><span data-i18n="notebook.title">Notebook</span> <span class="notebook-count" id="notebookCount">0 / 5</span></span>
|
||||
<button class="btn-close" id="btnCloseNotebook" data-i18n-title="dialog.close">✕</button>
|
||||
<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 -->
|
||||
@@ -105,29 +105,81 @@
|
||||
<div class="panel-overlay" id="settingsOverlay"></div>
|
||||
<aside class="settings-panel" id="settingsPanel">
|
||||
<div class="panel-header">
|
||||
<span data-i18n="settings.title">Einstellungen</span>
|
||||
<button class="btn-close" id="btnCloseSettings" data-i18n-title="dialog.close">✕</button>
|
||||
<span>Settings</span>
|
||||
<button class="btn-close" id="btnCloseSettings">✕</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<!-- SPRACHE -->
|
||||
<section class="settings-section" data-section="language">
|
||||
<!-- APPEARANCE -->
|
||||
<section class="settings-section" data-section="appearance">
|
||||
<button class="settings-section-title" type="button">
|
||||
<span class="section-chevron">▸</span>
|
||||
<span data-i18n="settings.section.display">DARSTELLUNG</span>
|
||||
APPEARANCE
|
||||
</button>
|
||||
<div class="section-content">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.language">Sprache</span>
|
||||
<span class="setting-desc" data-i18n="settings.language.desc">Anzeigesprache wählen</span>
|
||||
<span class="setting-label">Compact mode</span>
|
||||
<span class="setting-desc">Reduce spacing to show more bookmarks</span>
|
||||
</div>
|
||||
<select class="select-input" id="settingLanguage">
|
||||
<option value="auto" data-i18n="settings.language.auto">Automatisch</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<label class="toggle"><input type="checkbox" id="settingCompact" /><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Shorten long titles</span>
|
||||
<span class="setting-desc">Shorten title to one line with "…"</span>
|
||||
</div>
|
||||
<label class="toggle"><input type="checkbox" id="settingShorten" /><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BEHAVIOR -->
|
||||
<section class="settings-section" data-section="behavior">
|
||||
<button class="settings-section-title" type="button">
|
||||
<span class="section-chevron">▸</span>
|
||||
BEHAVIOR
|
||||
</button>
|
||||
<div class="section-content">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Open links in new tab</span>
|
||||
<span class="setting-desc">Open bookmarks in a new browser tab</span>
|
||||
</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">Suchleiste anzeigen</span>
|
||||
<span class="setting-desc">Suchleiste unter dem Header ein/aus</span>
|
||||
</div>
|
||||
<label class="toggle"><input type="checkbox" id="settingShowSearch" checked /><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -135,23 +187,23 @@
|
||||
<section class="settings-section" data-section="widgets">
|
||||
<button class="settings-section-title" type="button">
|
||||
<span class="section-chevron">▸</span>
|
||||
<span data-i18n="settings.section.widgets">WIDGETS</span>
|
||||
WIDGETS
|
||||
</button>
|
||||
<div class="section-content">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.toolbar_pos">Toolbar-Position</span>
|
||||
<span class="setting-desc" data-i18n="settings.toolbar_pos.desc">Widget-Toolbar links oder rechts anzeigen</span>
|
||||
<span class="setting-label">Toolbar-Position</span>
|
||||
<span class="setting-desc">Widget-Toolbar links oder rechts</span>
|
||||
</div>
|
||||
<select class="select-input" id="settingToolbarPos">
|
||||
<option value="right" selected data-i18n="settings.toolbar_pos.right">Rechts</option>
|
||||
<option value="left" data-i18n="settings.toolbar_pos.left">Links</option>
|
||||
<option value="right" selected>Rechts</option>
|
||||
<option value="left">Links</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.image_ref">Bild-Referenz Widgets</span>
|
||||
<span class="setting-desc" data-i18n="settings.image_ref.desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
|
||||
<span class="setting-label">Bild-Referenz Widgets</span>
|
||||
<span class="setting-desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="settingImageRef">
|
||||
@@ -161,41 +213,119 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DATEN & HILFE -->
|
||||
<!-- DATA -->
|
||||
<section class="settings-section" data-section="data">
|
||||
<button class="settings-section-title" type="button">
|
||||
<span class="section-chevron">▸</span>
|
||||
<span data-i18n="settings.section.data">DATEN & HILFE</span>
|
||||
DATA
|
||||
</button>
|
||||
<div class="section-content">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.export">Backup exportieren</span>
|
||||
<span class="setting-desc" data-i18n="settings.export.desc">Alle Boards, Notes und Einstellungen als JSON sichern</span>
|
||||
<span class="setting-label">Export Boards</span>
|
||||
<span class="setting-desc">Alle Boards als JSON sichern</span>
|
||||
</div>
|
||||
<button class="btn-small" id="btnExportJSON" data-i18n="settings.export.btn">Export</button>
|
||||
<button class="btn-small" id="btnExportJSON">Export</button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.import">Backup importieren</span>
|
||||
<span class="setting-desc" data-i18n="settings.import.desc">JSON-Backup wiederherstellen</span>
|
||||
<span class="setting-label">Import Boards</span>
|
||||
<span class="setting-desc">JSON-Backup wiederherstellen</span>
|
||||
</div>
|
||||
<button class="btn-small" id="btnImportJSON" data-i18n="header.import">Import</button>
|
||||
<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" data-i18n="settings.browser_import">Browser-Lesezeichen</span>
|
||||
<span class="setting-desc" data-i18n="settings.browser_import.desc">Lesezeichen direkt aus dem Browser importieren</span>
|
||||
</div>
|
||||
<button class="btn-small" id="btnBrowserImport" data-i18n="header.import">Import</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HELP -->
|
||||
<section class="settings-section" data-section="help">
|
||||
<button class="settings-section-title" type="button">
|
||||
<span class="section-chevron">▸</span>
|
||||
HELP
|
||||
</button>
|
||||
<div class="section-content">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.onboarding">Onboarding wiederholen</span>
|
||||
<span class="setting-desc" data-i18n="settings.onboarding.desc">Willkommens-Tour erneut anzeigen</span>
|
||||
<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>
|
||||
|
||||
<!-- ABOUT / IMPRESSUM -->
|
||||
<section class="settings-section" data-section="about">
|
||||
<button class="settings-section-title" type="button">
|
||||
<span class="section-chevron">▸</span>
|
||||
ABOUT
|
||||
</button>
|
||||
<div class="section-content">
|
||||
<div class="about-block">
|
||||
<div class="about-logo">⬡ HELLION NEWTAB</div>
|
||||
<div class="about-version">Version 1.5.2 · by Hellion Online Media</div>
|
||||
|
||||
<div class="about-links">
|
||||
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
Impressum
|
||||
</a>
|
||||
<a href="https://hellion-media.de" target="_blank" class="about-link">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||
hellion-media.de
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="about-divider"></div>
|
||||
|
||||
<div class="about-info-row">
|
||||
<span class="about-info-label">Entwickler</span>
|
||||
<span class="about-info-value">Florian Wathling</span>
|
||||
</div>
|
||||
<div class="about-info-row">
|
||||
<span class="about-info-label">Unternehmen</span>
|
||||
<span class="about-info-value">Hellion Online Media</span>
|
||||
</div>
|
||||
<div class="about-info-row">
|
||||
<span class="about-info-label">Lizenz</span>
|
||||
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" class="about-info-value about-link-subtle">CC BY-NC-SA 4.0</a>
|
||||
</div>
|
||||
<div class="about-info-row">
|
||||
<span class="about-info-label">Datenspeicherung</span>
|
||||
<span class="about-info-value">100% lokal · Kein Server · Kein Account</span>
|
||||
</div>
|
||||
|
||||
<div class="about-divider"></div>
|
||||
|
||||
<div class="about-bugreport">
|
||||
<span class="about-info-label about-info-label-block">Bug Report / Feedback</span>
|
||||
<a href="mailto:kontakt@hellion-media.de?subject=Hellion NewTab – Bug Report" class="about-link-mail">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
kontakt@hellion-media.de
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="about-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>
|
||||
<button class="btn-small" id="btnRestartOnboarding" data-i18n="settings.onboarding_btn">Start</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -204,111 +334,42 @@
|
||||
<section class="settings-section" data-section="danger">
|
||||
<button class="settings-section-title danger" type="button">
|
||||
<span class="section-chevron">▸</span>
|
||||
<span data-i18n="settings.section.danger">DANGER ZONE</span>
|
||||
DANGER ZONE
|
||||
</button>
|
||||
<div class="section-content">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.reset">Alles zurücksetzen</span>
|
||||
<span class="setting-desc" data-i18n="settings.reset.desc">Löscht alle Boards, Notes und Einstellungen</span>
|
||||
<span class="setting-label">Reset all data</span>
|
||||
<span class="setting-desc">Deletes all boards and bookmarks</span>
|
||||
</div>
|
||||
<button class="btn-danger" id="btnResetAll" data-i18n="settings.reset_btn">Reset</button>
|
||||
<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" data-i18n="about.title">⬡ HELLION NEWTAB</div>
|
||||
<div class="about-version">Version 2.1.0 · by Hellion Online Media</div>
|
||||
|
||||
<div class="about-links">
|
||||
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<span data-i18n="about.impressum">Impressum</span>
|
||||
</a>
|
||||
<a href="https://hellion-media.de" target="_blank" class="about-link">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||
hellion-media.de
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="about-divider"></div>
|
||||
|
||||
<div class="about-info-row">
|
||||
<span class="about-info-label" data-i18n="about.developer">Entwickler</span>
|
||||
<span class="about-info-value">Florian Wathling</span>
|
||||
</div>
|
||||
<div class="about-info-row">
|
||||
<span class="about-info-label" data-i18n="about.company">Unternehmen</span>
|
||||
<span class="about-info-value">Hellion Online Media</span>
|
||||
</div>
|
||||
<div class="about-info-row">
|
||||
<span class="about-info-label" data-i18n="about.license">Lizenz</span>
|
||||
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" class="about-info-value about-link-subtle">CC BY-NC-SA 4.0</a>
|
||||
</div>
|
||||
<div class="about-info-row">
|
||||
<span class="about-info-label" data-i18n="about.storage">Datenspeicherung</span>
|
||||
<span class="about-info-value" data-i18n="about.storage.value">100% lokal · Kein Server · Kein Account</span>
|
||||
</div>
|
||||
|
||||
<div class="about-divider"></div>
|
||||
|
||||
<div class="about-bugreport">
|
||||
<span class="about-info-label about-info-label-block" data-i18n="about.bugreport">Bug Report / Feedback</span>
|
||||
<a href="mailto:kontakt@hellion-media.de?subject=Hellion NewTab – Bug Report" class="about-link-mail">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
kontakt@hellion-media.de
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="about-bugreport">
|
||||
<span class="about-info-label about-info-label-block" data-i18n="about.support">Support</span>
|
||||
<a href="https://ko-fi.com/hellionmedia" target="_blank" class="about-link">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 010 8h-1"/><path d="M2 8h16v9a4 4 0 01-4 4H6a4 4 0 01-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>
|
||||
Ko-fi — hellionmedia
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="about-browsers">
|
||||
<span class="about-info-label about-info-label-block" data-i18n="about.browsers">Kompatible Browser</span>
|
||||
<div class="about-browser-tags">
|
||||
<span class="browser-tag">Chrome</span>
|
||||
<span class="browser-tag">Edge</span>
|
||||
<span class="browser-tag">Firefox</span>
|
||||
<span class="browser-tag">Opera</span>
|
||||
<span class="browser-tag">Opera GX</span>
|
||||
<span class="browser-tag">Brave</span>
|
||||
<span class="browser-tag">Vivaldi</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- THEME PICKER MODAL -->
|
||||
<div class="modal-overlay" id="themeOverlay">
|
||||
<div class="theme-modal" id="themeModal">
|
||||
<div class="modal-header">
|
||||
<span data-i18n="modal.theme_header">Darstellung</span>
|
||||
<button class="btn-close" id="btnCloseTheme" data-i18n-title="dialog.close">✕</button>
|
||||
<span>Theme wählen</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" />
|
||||
<img class="theme-card-img" src="assets/themes/bg-nebula.jpg" alt="Nebula" />
|
||||
<span class="theme-card-label">Nebula</span>
|
||||
<span class="theme-card-check">✓</span>
|
||||
</div>
|
||||
<div class="theme-card" data-value="crescent">
|
||||
<img class="theme-card-img" src="assets/themes/bg-crescent.webp" alt="Crescent" />
|
||||
<img class="theme-card-img" src="assets/themes/bg-crescent.jpg" alt="Crescent" />
|
||||
<span class="theme-card-label">Crescent</span>
|
||||
<span class="theme-card-check">✓</span>
|
||||
</div>
|
||||
<div class="theme-card" data-value="event-horizon">
|
||||
<img class="theme-card-img" src="assets/themes/bg-event-horizon.webp" alt="Event Horizon" />
|
||||
<img class="theme-card-img" src="assets/themes/bg-event-horizon.jpg" alt="Event Horizon" />
|
||||
<span class="theme-card-label">Event Horizon</span>
|
||||
<span class="theme-card-check">✓</span>
|
||||
</div>
|
||||
@@ -318,119 +379,48 @@
|
||||
<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" />
|
||||
<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.webp" alt="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.webp" alt="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.webp" alt="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 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" data-i18n="settings.section.bg">HINTERGRUND</h3>
|
||||
<h3 class="settings-section-title">BACKGROUND</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.bg_url">Bild-URL</span>
|
||||
<span class="setting-desc" data-i18n="settings.bg_url.desc">Eigenes Hintergrundbild per URL</span>
|
||||
<span class="setting-label">Background image URL</span>
|
||||
<span class="setting-desc">Custom wallpaper URL</span>
|
||||
</div>
|
||||
<button class="btn-small" id="btnChangeBg" data-i18n="settings.bg_change">Ändern</button>
|
||||
<button class="btn-small" id="btnChangeBg">Change</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" data-i18n="settings.bg_apply">Übernehmen</button>
|
||||
<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" data-i18n="settings.bg_upload">Datei hochladen</span>
|
||||
<span class="setting-desc" data-i18n="settings.bg_upload.desc">Lokales Bild als Hintergrund verwenden</span>
|
||||
<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" data-i18n="settings.bg_upload_btn">Upload</button>
|
||||
<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" data-i18n="settings.section.display">DARSTELLUNG</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.compact">Kompaktmodus</span>
|
||||
<span class="setting-desc" data-i18n="settings.compact.desc">Weniger Abstand für mehr Bookmarks</span>
|
||||
</div>
|
||||
<label class="toggle"><input type="checkbox" id="settingCompact" /><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.shorten">Lange Titel kürzen</span>
|
||||
<span class="setting-desc" data-i18n="settings.shorten.desc">Titel auf eine Zeile mit „…" kürzen</span>
|
||||
</div>
|
||||
<label class="toggle"><input type="checkbox" id="settingShorten" /><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.search">Suchleiste anzeigen</span>
|
||||
<span class="setting-desc" data-i18n="settings.search.desc">Suchleiste unter dem Header ein/aus</span>
|
||||
</div>
|
||||
<label class="toggle"><input type="checkbox" id="settingShowSearch" checked /><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.newtab">Links in neuem Tab</span>
|
||||
<span class="setting-desc" data-i18n="settings.newtab.desc">Bookmarks in neuem Browser-Tab öffnen</span>
|
||||
</div>
|
||||
<label class="toggle"><input type="checkbox" id="settingNewTab" checked /><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.showdesc">Beschreibungen anzeigen</span>
|
||||
<span class="setting-desc" data-i18n="settings.showdesc.desc">Gespeicherte Beschreibung unter Bookmarks</span>
|
||||
</div>
|
||||
<label class="toggle"><input type="checkbox" id="settingShowDesc" /><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.hideextra">Bookmarks ausblenden</span>
|
||||
<span class="setting-desc" data-i18n="settings.hideextra.desc">Überzählige Bookmarks in langen Boards verstecken</span>
|
||||
</div>
|
||||
<label class="toggle"><input type="checkbox" id="settingHideExtra" /><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="setting-row" id="visibleCountRow">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.visible_count">Sichtbare Bookmarks</span>
|
||||
<span class="setting-desc" data-i18n="settings.visible_count.desc">Anzahl vor dem Ausblenden</span>
|
||||
</div>
|
||||
<select class="select-input" id="settingVisibleCount">
|
||||
<option value="5">5</option>
|
||||
<option value="10" selected>10</option>
|
||||
<option value="20">20</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -438,14 +428,14 @@
|
||||
<div class="modal-overlay" id="addBoardOverlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span data-i18n="modal.new_board">New Board</span>
|
||||
<button class="btn-close" id="btnCancelBoard" data-i18n-title="dialog.close">✕</button>
|
||||
<span>New Board</span>
|
||||
<button class="btn-close" id="btnCancelBoard">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" class="text-input full-width" id="newBoardName" data-i18n-placeholder="modal.board_name" placeholder="Board name..." maxlength="40" />
|
||||
<input type="text" class="text-input full-width" id="newBoardName" placeholder="Board name..." maxlength="40" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-primary" id="btnConfirmBoard" data-i18n="modal.create">Create</button>
|
||||
<button class="btn-primary" id="btnConfirmBoard">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -454,16 +444,16 @@
|
||||
<div class="modal-overlay" id="addBookmarkOverlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span data-i18n="modal.new_bookmark">New Bookmark</span>
|
||||
<button class="btn-close" id="btnCancelBookmark" data-i18n-title="dialog.close">✕</button>
|
||||
<span>New Bookmark</span>
|
||||
<button class="btn-close" id="btnCancelBookmark">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" class="text-input full-width" id="newBmTitle" data-i18n-placeholder="modal.bm_title" 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 modal-input-spaced" id="newBmUrl" placeholder="https://..." />
|
||||
<input type="text" class="text-input full-width modal-input-spaced" id="newBmDesc" data-i18n-placeholder="modal.bm_desc" placeholder="Description (optional)" />
|
||||
<input type="text" class="text-input full-width modal-input-spaced" id="newBmDesc" placeholder="Description (optional)" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-primary" id="btnConfirmBookmark" data-i18n="modal.bm_add">Add</button>
|
||||
<button class="btn-primary" id="btnConfirmBookmark">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,14 +462,14 @@
|
||||
<div class="modal-overlay" id="renameOverlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span data-i18n="modal.rename">Rename</span>
|
||||
<button class="btn-close" id="btnCancelRename" data-i18n-title="dialog.close">✕</button>
|
||||
<span>Rename</span>
|
||||
<button class="btn-close" id="btnCancelRename">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" class="text-input full-width" id="renameInput" data-i18n-placeholder="modal.rename_placeholder" placeholder="New name..." maxlength="60" />
|
||||
<input type="text" class="text-input full-width" id="renameInput" placeholder="New name..." maxlength="60" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-primary" id="btnConfirmRename" data-i18n="modal.rename_confirm">Rename</button>
|
||||
<button class="btn-primary" id="btnConfirmRename">Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -493,8 +483,6 @@
|
||||
<script src="src/js/storage.js"></script>
|
||||
<!-- State & Hilfsfunktionen -->
|
||||
<script src="src/js/state.js"></script>
|
||||
<!-- i18n (nach state.js, vor allen Modulen die t() nutzen könnten) -->
|
||||
<script src="src/js/i18n.js"></script>
|
||||
<!-- Dialog-System (vor Features, wird überall gebraucht) -->
|
||||
<script src="src/js/dialog.js"></script>
|
||||
<!-- Theme-System -->
|
||||
@@ -507,14 +495,8 @@
|
||||
<script src="src/js/widgets.js"></script>
|
||||
<script src="src/js/notes.js"></script>
|
||||
<script src="src/js/calculator.js"></script>
|
||||
<script src="src/js/calc-scientific.js"></script>
|
||||
<script src="src/js/calc-converter.js"></script>
|
||||
<script src="src/js/calc-satisfactory.js"></script>
|
||||
<script src="src/js/calc-factorio.js"></script>
|
||||
<script src="src/js/calc-stationeers.js"></script>
|
||||
<script src="src/js/timer.js"></script>
|
||||
<script src="src/js/image-ref.js"></script>
|
||||
<script src="src/js/bookmark-import.js"></script>
|
||||
<script src="src/js/data.js"></script>
|
||||
<!-- Onboarding -->
|
||||
<script src="src/js/onboarding.js"></script>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":dependencyDashboard",
|
||||
":semanticCommits",
|
||||
":timezone(Europe/Berlin)",
|
||||
"schedule:weekly"
|
||||
],
|
||||
"labels": ["dependencies", "renovate"],
|
||||
"assignees": ["JonKazama-Hellion"],
|
||||
"prHourlyLimit": 10,
|
||||
"prConcurrentLimit": 20,
|
||||
"rebaseWhen": "behind-base-branch",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group all minor and patch updates per ecosystem in one PR",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "minor and patch updates ({{manager}})"
|
||||
},
|
||||
{
|
||||
"description": "Major updates always get their own PR with breaking-change label",
|
||||
"matchUpdateTypes": ["major"],
|
||||
"labels": ["dependencies", "major-update", "breaking-change"],
|
||||
"addLabels": ["needs-review"]
|
||||
},
|
||||
{
|
||||
"description": "TypeScript type definitions stay grouped with each other",
|
||||
"matchPackagePrefixes": ["@types/"],
|
||||
"groupName": "type definitions"
|
||||
},
|
||||
{
|
||||
"description": "Dev dependencies in their own group",
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"groupName": "dev dependencies"
|
||||
},
|
||||
{
|
||||
"description": "Pin GitHub Action versions by SHA for supply-chain hygiene",
|
||||
"matchManagers": ["github-actions"],
|
||||
"pinDigests": true
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
"labels": ["security", "vulnerability"],
|
||||
"schedule": ["at any time"],
|
||||
"prPriority": 10
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": ["before 6am on monday"],
|
||||
"commitMessageAction": "Refresh"
|
||||
},
|
||||
"osvVulnerabilityAlerts": true
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# ⬡ Hellion Dashboard — Design & Theme-System
|
||||
|
||||
Leitfaden für das visuelle Design des Hellion Dashboards. Definiert wie Themes aufgebaut
|
||||
sind und welche Patterns konsistent eingehalten werden — für eine immersive, fokussierte
|
||||
Nutzererfahrung.
|
||||
|
||||
---
|
||||
|
||||
## Design-Säulen
|
||||
|
||||
| Säule | Beschreibung |
|
||||
|---|---|
|
||||
| **Immersion** | Das Interface wirkt wie ein HUD das über der Szenerie schwebt — kein Fremdkörper |
|
||||
| **Visual Clarity** | Gezielter `blur`-Einsatz trennt UI und Hintergrundbild — reduziert Reizüberflutung |
|
||||
| **Harmonie** | Jedes Theme zieht seine Farben aus den dominanten Lichtquellen des Hintergrundbildes |
|
||||
|
||||
---
|
||||
|
||||
## Anatomie eines Themes
|
||||
|
||||
Jedes Theme folgt dieser Variablen-Struktur in `main.css`.
|
||||
Für ein neues Theme diesen Block kopieren und anpassen:
|
||||
|
||||
```css
|
||||
[data-theme="dein-theme-name"] {
|
||||
/* 1. AKZENTE — Die Lichtquelle */
|
||||
--accent: #HEXCODE; /* Hauptfarbe (Neon/Licht) */
|
||||
--accent-dim: rgba(R, G, B, 0.12); /* Subtiler Hintergrund */
|
||||
--accent-glow: rgba(R, G, B, 0.08); /* Glow für Logo & Uhr */
|
||||
--border-accent: rgba(R, G, B, 0.25); /* Fokus-Rahmen */
|
||||
|
||||
/* 2. BASIS — Das Fundament */
|
||||
--bg-primary: #HEXCODE; /* Dunkelster Punkt im Bild */
|
||||
--bg-board: rgba(R, G, B, 0.55); /* Glas-Effekt der Boards */
|
||||
|
||||
/* 3. TEXT — Kontrast */
|
||||
--text-primary: #FFFFFF; /* Klar lesbar, leicht getönt */
|
||||
--text-secondary: #A0A0A0; /* Entsättigt für weniger Rauschen */
|
||||
|
||||
/* 4. OVERLAY — Vignette */
|
||||
--overlay-bg: radial-gradient(
|
||||
circle at center,
|
||||
transparent 0%,
|
||||
var(--bg-primary) 100%
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI-Patterns
|
||||
|
||||
### Frosted Glass
|
||||
|
||||
Hardware-beschleunigter Blur für Lesbarkeit auf komplexen Hintergründen:
|
||||
|
||||
```css
|
||||
backdrop-filter: blur(8px);
|
||||
```
|
||||
|
||||
Erzeugt Tiefe und visuelle Ruhe hinter Text und UI-Elementen.
|
||||
|
||||
### Typografie-Hierarchie
|
||||
|
||||
| Font | Einsatz |
|
||||
|---|---|
|
||||
| **Rajdhani** | Display — Uhr, Titel, Logo. Alles was nach "System" aussieht |
|
||||
| **Inter** | Body — Bookmark-Titel, Listen, interaktive Elemente |
|
||||
| **Cinzel** | Fantasy — Exklusiv für Themes mit majestätischem oder antikem Vibe (Crescent, Julia & Jin) |
|
||||
|
||||
---
|
||||
|
||||
## Theme-Übersicht
|
||||
|
||||
| Theme | Akzentfarbe | Stimmung |
|
||||
|---|---|---|
|
||||
| Nebula | `#b359ff` Magenta | Chill, Cosmic |
|
||||
| Crescent | `#d4bd8a` Gold | Luxury, Night |
|
||||
| Event Horizon | `#9d5cff` Purple | Deep Space, Void |
|
||||
| Merchantman | `#2eb8b8` Emerald | Industrial, Alien |
|
||||
| Julia & Jin | `#7db3ff` Aetherial Blue | FFXIV Night |
|
||||
| SC Sunset | `#ff8c3d` Amber | Emotional, Horizon |
|
||||
| Hellion HUD | `#32ff6a` Neon Green | Tactical, Admin |
|
||||
| Hellion Energy | `#1eff8e` Acid Green | Overdrive, Power |
|
||||
|
||||
---
|
||||
|
||||
## ADHS-Optimierung
|
||||
|
||||
Bei Hintergrundbildern mit vielen Details (z.B. Julia & Jin) den Board-Alpha erhöhen
|
||||
und den Blur verstärken — das dimmt das Hintergrundrauschen und lässt das Gehirn
|
||||
schneller die relevanten Informationen erfassen:
|
||||
|
||||
```css
|
||||
--bg-board: rgba(R, G, B, 0.65);
|
||||
backdrop-filter: blur(12px);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Entwickelt von **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion
|
||||
@@ -68,19 +68,6 @@
|
||||
--board-hover-border: rgba(179,89,255,0.18);
|
||||
--toggle-on-bg: rgba(214,92,255,0.22);
|
||||
--logo-shadow: rgba(179,89,255,0.35);
|
||||
--bg-solid-fallback: #0a060e;
|
||||
}
|
||||
|
||||
/* Fallback fuer Browser die backdrop-filter blockieren (z.B. Brave Shields) */
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
.board,
|
||||
.widget,
|
||||
.settings-panel,
|
||||
.dialog-box,
|
||||
.theme-modal,
|
||||
.search-bar {
|
||||
background-color: var(--bg-solid-fallback, var(--bg-primary));
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -104,7 +91,6 @@
|
||||
--board-hover-border: rgba(179,89,255,0.18);
|
||||
--toggle-on-bg: rgba(214,92,255,0.22);
|
||||
--logo-shadow: rgba(179,89,255,0.35);
|
||||
--bg-solid-fallback: #0a060e;
|
||||
}
|
||||
[data-theme="nebula"] .board { border-color: rgba(214,92,255,0.10); }
|
||||
[data-theme="nebula"] .bm-item:hover { background: rgba(214,92,255,0.05); }
|
||||
@@ -130,7 +116,6 @@
|
||||
--board-hover-border: rgba(212, 189, 138, 0.20);
|
||||
--toggle-on-bg: rgba(200,168,74,0.22);
|
||||
--logo-shadow: rgba(212, 189, 138, 0.40);
|
||||
--bg-solid-fallback: #080c16;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -161,7 +146,6 @@
|
||||
--board-hover-border: rgba(157, 92, 255, 0.22);
|
||||
--toggle-on-bg: rgba(224,128,48,0.22);
|
||||
--logo-shadow: rgba(157, 92, 255, 0.45);
|
||||
--bg-solid-fallback: #08050f;
|
||||
}
|
||||
[data-theme="event-horizon"] .board { border-color: rgba(157, 92, 255, 0.15); }
|
||||
[data-theme="event-horizon"] .bm-item:hover { background: rgba(157, 92, 255, 0.08); }
|
||||
@@ -188,7 +172,6 @@
|
||||
--board-hover-border: rgba(46, 184, 184, 0.20);
|
||||
--toggle-on-bg: rgba(78,207,207,0.22);
|
||||
--logo-shadow: rgba(46, 184, 184, 0.45);
|
||||
--bg-solid-fallback: #060a0a;
|
||||
}
|
||||
[data-theme="merchantman"] .board { border-color: rgba(46, 184, 184, 0.12); }
|
||||
[data-theme="merchantman"] .bm-item:hover { background: rgba(46, 184, 184, 0.06); }
|
||||
@@ -214,7 +197,6 @@
|
||||
--board-hover-border: rgba(125, 179, 255, 0.22);
|
||||
--toggle-on-bg: rgba(91,159,255,0.22);
|
||||
--logo-shadow: rgba(125, 179, 255, 0.50);
|
||||
--bg-solid-fallback: #070a14;
|
||||
}
|
||||
[data-theme="julia-jin"] .logo { font-family: 'Cinzel', serif; letter-spacing: 5px; text-transform: uppercase; }
|
||||
[data-theme="julia-jin"] .clock { font-family: 'Cinzel', serif; font-weight: 600; }
|
||||
@@ -243,7 +225,6 @@
|
||||
--board-hover-border: rgba(255, 140, 61, 0.22);
|
||||
--toggle-on-bg: rgba(240,124,48,0.22);
|
||||
--logo-shadow: rgba(255, 140, 61, 0.45);
|
||||
--bg-solid-fallback: #0f0a08;
|
||||
}
|
||||
[data-theme="sc-sunset"] .board {
|
||||
border-color: rgba(255, 140, 61, 0.15);
|
||||
@@ -272,7 +253,6 @@
|
||||
--board-hover-border: rgba(50, 255, 106, 0.20);
|
||||
--toggle-on-bg: rgba(34,204,68,0.20);
|
||||
--logo-shadow: rgba(50, 255, 106, 0.40);
|
||||
--bg-solid-fallback: #050805;
|
||||
--danger: #ff4d4d;
|
||||
}
|
||||
[data-theme="hellion-hud"] .board {
|
||||
@@ -307,7 +287,6 @@
|
||||
--board-hover-border: rgba(30, 255, 142, 0.25);
|
||||
--toggle-on-bg: rgba(0,232,122,0.18);
|
||||
--logo-shadow: rgba(30, 255, 142, 0.60);
|
||||
--bg-solid-fallback: #040705;
|
||||
}
|
||||
[data-theme="hellion-energy"] .board {
|
||||
border-color: rgba(30, 255, 142, 0.15);
|
||||
@@ -319,100 +298,6 @@
|
||||
box-shadow: inset 0 0 10px rgba(30, 255, 142, 0.05);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
THEME: SATISFACTORY (Industrial Desert)
|
||||
============================================ */
|
||||
[data-theme="satisfactory"] {
|
||||
--accent: #00b4d8;
|
||||
--accent-dim: rgba(0, 180, 216, 0.12);
|
||||
--accent-glow: rgba(0, 180, 216, 0.08);
|
||||
--border-accent: rgba(0, 180, 216, 0.35);
|
||||
--bg-primary: #1a0f08;
|
||||
--bg-board: rgba(26, 15, 8, 0.65);
|
||||
--border: rgba(0, 180, 216, 0.15);
|
||||
--text-primary: #f0faff;
|
||||
--text-secondary: #a89f98;
|
||||
--text-muted: #635a54;
|
||||
--font-display: 'Rajdhani', sans-serif;
|
||||
--font-body: 'Inter', sans-serif;
|
||||
--overlay-bg: linear-gradient(180deg,
|
||||
rgba(26,15,8,0.85) 0%,
|
||||
rgba(26,15,8,0.15) 50%,
|
||||
rgba(26,15,8,0.90) 100%);
|
||||
--header-bg: rgba(26,15,8,0.95);
|
||||
--board-hover-border: rgba(0, 180, 216, 0.25);
|
||||
--toggle-on-bg: rgba(0, 180, 216, 0.20);
|
||||
--logo-shadow: rgba(0, 180, 216, 0.40);
|
||||
--bg-solid-fallback: #1a0f08;
|
||||
}
|
||||
[data-theme="satisfactory"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 3px; text-transform: uppercase; }
|
||||
[data-theme="satisfactory"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); }
|
||||
[data-theme="satisfactory"] .board-title { font-family: 'Rajdhani', sans-serif; letter-spacing: 1.5px; text-transform: uppercase; }
|
||||
[data-theme="satisfactory"] .board { border-color: rgba(0, 180, 216, 0.20); backdrop-filter: blur(12px); }
|
||||
[data-theme="satisfactory"] .bm-item:hover { background: rgba(0, 180, 216, 0.10); }
|
||||
|
||||
/* ============================================
|
||||
THEME: AVORION (Deep Void)
|
||||
============================================ */
|
||||
[data-theme="avorion"] {
|
||||
--accent: #2ec4a0;
|
||||
--accent-dim: rgba(46, 196, 160, 0.12);
|
||||
--accent-glow: rgba(46, 196, 160, 0.08);
|
||||
--border-accent: rgba(46, 196, 160, 0.30);
|
||||
--bg-primary: #020d0c;
|
||||
--bg-board: rgba(2, 13, 12, 0.60);
|
||||
--border: rgba(46, 196, 160, 0.12);
|
||||
--text-primary: #e6fffa;
|
||||
--text-secondary: #8abdb3;
|
||||
--text-muted: #40615a;
|
||||
--font-display: 'Rajdhani', sans-serif;
|
||||
--font-body: 'Inter', sans-serif;
|
||||
--overlay-bg: radial-gradient(circle at center,
|
||||
transparent 0%,
|
||||
rgba(2, 13, 12, 0.95) 100%);
|
||||
--header-bg: rgba(2, 13, 12, 0.94);
|
||||
--board-hover-border: rgba(46, 196, 160, 0.22);
|
||||
--toggle-on-bg: rgba(46, 196, 160, 0.18);
|
||||
--logo-shadow: rgba(46, 196, 160, 0.50);
|
||||
--bg-solid-fallback: #020d0c;
|
||||
}
|
||||
[data-theme="avorion"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 500; letter-spacing: 6px; text-transform: uppercase; }
|
||||
[data-theme="avorion"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 400; color: var(--accent); }
|
||||
[data-theme="avorion"] .board-title { font-family: 'Rajdhani', sans-serif; font-weight: 500; text-transform: uppercase; }
|
||||
[data-theme="avorion"] .board { border-color: rgba(46, 196, 160, 0.15); backdrop-filter: blur(8px); }
|
||||
[data-theme="avorion"] .bm-item:hover { background: rgba(46, 196, 160, 0.08); }
|
||||
|
||||
/* ============================================
|
||||
THEME: HELLION STEALTH (Tactical Recon)
|
||||
============================================ */
|
||||
[data-theme="hellion-stealth"] {
|
||||
--accent: #5ec2ff;
|
||||
--accent-dim: rgba(94, 194, 255, 0.12);
|
||||
--accent-glow: rgba(94, 194, 255, 0.08);
|
||||
--border-accent: rgba(94, 194, 255, 0.35);
|
||||
--bg-primary: #0d0f12;
|
||||
--bg-board: rgba(13, 15, 18, 0.70);
|
||||
--border: rgba(94, 194, 255, 0.15);
|
||||
--text-primary: #e0f4ff;
|
||||
--text-secondary: #8a9499;
|
||||
--text-muted: #4a5257;
|
||||
--font-display: 'Rajdhani', sans-serif;
|
||||
--font-body: 'Inter', sans-serif;
|
||||
--overlay-bg: radial-gradient(circle at center,
|
||||
transparent 0%,
|
||||
rgba(13, 15, 18, 0.90) 100%);
|
||||
--header-bg: rgba(13, 15, 18, 0.96);
|
||||
--board-hover-border: rgba(94, 194, 255, 0.25);
|
||||
--toggle-on-bg: rgba(94, 194, 255, 0.20);
|
||||
--logo-shadow: rgba(94, 194, 255, 0.45);
|
||||
--bg-solid-fallback: #0d0f12;
|
||||
}
|
||||
[data-theme="hellion-stealth"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 4px; }
|
||||
[data-theme="hellion-stealth"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); }
|
||||
[data-theme="hellion-stealth"] .board-title { text-transform: uppercase; font-size: 0.85rem; letter-spacing: 2px; }
|
||||
[data-theme="hellion-stealth"] .board { border-color: rgba(94, 194, 255, 0.15); backdrop-filter: blur(10px); }
|
||||
[data-theme="hellion-stealth"] .bm-item:hover { background: rgba(94, 194, 255, 0.10); border-left: 2px solid var(--accent); }
|
||||
|
||||
/* ============================================
|
||||
BASE STYLES
|
||||
============================================ */
|
||||
@@ -586,13 +471,12 @@ html, body {
|
||||
|
||||
body.compact .bm-item { padding: var(--spacing-compact) 10px; }
|
||||
|
||||
.bm-favicon-local {
|
||||
width: 16px; height: 16px; flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
.bm-favicon { width: 14px; height: 14px; flex-shrink: 0; border-radius: 2px; opacity: 0.85; }
|
||||
.bm-favicon-fallback {
|
||||
width: 14px; height: 14px; flex-shrink: 0;
|
||||
background: var(--accent-dim); border-radius: 2px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 9px; font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
font-size: 8px; color: var(--accent);
|
||||
}
|
||||
.bm-text { flex: 1; min-width: 0; }
|
||||
.bm-title { font-size: 12px; font-weight: 400; color: var(--text-primary); line-height: 1.3; }
|
||||
@@ -653,13 +537,7 @@ body.show-desc .bm-desc { display: block; }
|
||||
font-family: var(--font-display); font-size: 15px; font-weight: 600;
|
||||
letter-spacing: 2px; color: var(--accent); text-transform: uppercase;
|
||||
}
|
||||
.panel-body { flex: 1; overflow-y: auto; padding: 12px 0; scrollbar-width: thin; scrollbar-color: var(--border) transparent; min-height: 0; }
|
||||
.panel-footer {
|
||||
flex-shrink: 0; border-top: 1px solid var(--border);
|
||||
max-height: 45vh; overflow-y: auto;
|
||||
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
.panel-footer .about-block { padding: 12px 18px 16px; }
|
||||
.panel-body { flex: 1; overflow-y: auto; padding: 12px 0; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
|
||||
.settings-section { margin-bottom: 4px; }
|
||||
.settings-section-title {
|
||||
@@ -728,9 +606,6 @@ body.show-desc .bm-desc { display: block; }
|
||||
.theme-card[data-value="sc-sunset"] .theme-card-label { color: #ff8c3d; } /* Amber Sunset */
|
||||
.theme-card[data-value="hellion-hud"] .theme-card-label { color: #32ff6a; } /* Neon Green */
|
||||
.theme-card[data-value="hellion-energy"] .theme-card-label { color: #1eff8e; } /* Acid Green */
|
||||
.theme-card[data-value="satisfactory"] .theme-card-label { color: #00b4d8; } /* Cyan LED */
|
||||
.theme-card[data-value="avorion"] .theme-card-label { color: #2ec4a0; } /* Turquoise */
|
||||
.theme-card[data-value="hellion-stealth"] .theme-card-label { color: #5ec2ff; } /* Tech Blue */
|
||||
.theme-card:hover .theme-card-label {
|
||||
text-shadow: 0 0 8px currentColor;
|
||||
transition: text-shadow 0.2s ease;
|
||||
@@ -764,12 +639,11 @@ body.show-desc .bm-desc { display: block; }
|
||||
|
||||
/* INPUTS */
|
||||
.select-input {
|
||||
padding: 5px 8px; background: var(--bg-board, rgba(255,255,255,0.06));
|
||||
padding: 5px 8px; background: rgba(255,255,255,0.06);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
color: var(--text-primary); font-family: var(--font-body); font-size: 12px;
|
||||
cursor: pointer; min-width: 70px;
|
||||
}
|
||||
.select-input option { background: var(--bg-primary, #0a0e17); color: var(--text-primary); }
|
||||
.select-input:focus { outline: none; border-color: var(--border-accent); }
|
||||
|
||||
.text-input {
|
||||
@@ -1282,299 +1156,6 @@ body.show-desc .bm-desc { display: block; }
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Calculator Tab System */
|
||||
.calc-tab-bar {
|
||||
display: flex;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.calc-tab-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.calc-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.calc-tab:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.calc-tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.calc-tab-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
.calc-tab-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.calc-mode-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Calculator Scientific Mode */
|
||||
.calc-sci-buttons {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.calc-formula-helper {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.calc-formula-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.calc-formula-select {
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.calc-formula-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.calc-formula-row label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 50px;
|
||||
}
|
||||
.calc-formula-input {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
}
|
||||
.calc-formula-result {
|
||||
font-size: 14px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-family: 'Rajdhani', monospace;
|
||||
text-align: right;
|
||||
min-height: 20px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* Calculator Converter Mode */
|
||||
.calc-conv-select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
}
|
||||
.calc-conv-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.calc-conv-input {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-family: 'Rajdhani', monospace;
|
||||
}
|
||||
.calc-conv-input:read-only {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.calc-conv-unit {
|
||||
width: 80px;
|
||||
padding: 4px 6px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
}
|
||||
.calc-conv-swap {
|
||||
align-self: center;
|
||||
width: 36px;
|
||||
height: 28px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.calc-conv-swap:hover {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.calc-conv-ref {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Calculator Game Modes (shared) */
|
||||
.calc-game-subtabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.calc-game-subtab {
|
||||
flex: 1;
|
||||
padding: 5px 4px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.calc-game-subtab:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.calc-game-subtab.active {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.calc-game-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.calc-game-field label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 90px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.calc-game-input {
|
||||
flex: 1;
|
||||
padding: 5px 8px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: 'Rajdhani', monospace;
|
||||
}
|
||||
.calc-game-output {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.calc-game-output span:first-child {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.calc-game-value {
|
||||
font-size: 14px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-family: 'Rajdhani', monospace;
|
||||
}
|
||||
.calc-game-warning {
|
||||
font-size: 10px;
|
||||
color: var(--danger);
|
||||
padding: 2px 0;
|
||||
}
|
||||
.calc-game-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Calculator Stationeers specifics */
|
||||
.calc-game-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin-top: -4px;
|
||||
text-align: right;
|
||||
}
|
||||
.calc-game-details {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.calc-game-details summary {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.calc-game-table {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
border-collapse: collapse;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.calc-game-table th {
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.calc-game-table td {
|
||||
padding: 2px 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.calc-game-table tr:nth-child(even) td {
|
||||
background: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIMER WIDGET
|
||||
============================================ */
|
||||
@@ -2213,90 +1794,6 @@ body.show-desc .bm-desc { display: block; }
|
||||
display: flex; gap: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BROWSER BOOKMARK IMPORT MODAL
|
||||
============================================ */
|
||||
.bm-import-overlay {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
background: rgba(0,0,0,0.6); backdrop-filter: blur(6px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.bm-import-overlay.active { opacity: 1; pointer-events: all; }
|
||||
|
||||
.bm-import-modal {
|
||||
background: rgba(8,8,16,0.96);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: blur(28px);
|
||||
max-width: 480px; width: 90%;
|
||||
max-height: 75vh;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateY(12px) scale(0.97);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
.bm-import-overlay.active .bm-import-modal {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.bm-import-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 18px; border-bottom: 1px solid var(--border);
|
||||
font-size: 14px; font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.bm-import-close {
|
||||
background: none; border: none; color: var(--text-muted);
|
||||
font-size: 20px; cursor: pointer; padding: 0 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.bm-import-close:hover { color: var(--text-primary); }
|
||||
|
||||
.bm-import-info {
|
||||
padding: 10px 18px;
|
||||
font-size: 12px; color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bm-import-list {
|
||||
flex: 1; overflow-y: auto; padding: 4px 0;
|
||||
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
|
||||
max-height: 45vh;
|
||||
}
|
||||
|
||||
.bm-import-folder {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px; cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
font-size: 13px; color: var(--text-primary);
|
||||
}
|
||||
.bm-import-folder:hover {
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.bm-import-checkbox {
|
||||
accent-color: var(--accent);
|
||||
width: 16px; height: 16px;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bm-import-folder-name {
|
||||
flex: 1; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.bm-import-folder-meta {
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bm-import-footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 18px; border-top: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
THEME PICKER MODAL
|
||||
============================================ */
|
||||
|
||||
@@ -10,7 +10,6 @@ async function init() {
|
||||
boards = savedBoards ?? getDefaultBoards();
|
||||
if (savedSettings) Object.assign(settings, savedSettings);
|
||||
|
||||
I18n.init();
|
||||
applySettings();
|
||||
renderBoards();
|
||||
startClock();
|
||||
@@ -22,7 +21,6 @@ async function init() {
|
||||
await Calculator.init();
|
||||
await Timer.init();
|
||||
await ImageRef.init();
|
||||
BrowserBookmarkImport.init();
|
||||
initDataButtons();
|
||||
Store.checkQuota();
|
||||
|
||||
@@ -95,8 +93,8 @@ async function checkBackupReminder() {
|
||||
if (boards.length === 0) return;
|
||||
|
||||
const doBackup = await HellionDialog.confirm(
|
||||
t('app.backup_reminder'),
|
||||
{ type: 'warning', title: t('app.backup_reminder.title'), confirmText: t('app.backup_now'), cancelText: t('app.backup_later') }
|
||||
'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) {
|
||||
@@ -105,7 +103,7 @@ async function checkBackupReminder() {
|
||||
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
|
||||
const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : [];
|
||||
const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : [];
|
||||
const data = { version: '2.1.0', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets };
|
||||
const data = { version: '1.7.0', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets };
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -121,18 +119,18 @@ async function checkBackupReminder() {
|
||||
|
||||
// ---- CLOCK & DATE ----
|
||||
function startClock() {
|
||||
const DAY_KEYS = ['clock.days.sun','clock.days.mon','clock.days.tue','clock.days.wed','clock.days.thu','clock.days.fri','clock.days.sat'];
|
||||
const MONTH_KEYS = ['clock.months.jan','clock.months.feb','clock.months.mar','clock.months.apr','clock.months.may','clock.months.jun','clock.months.jul','clock.months.aug','clock.months.sep','clock.months.oct','clock.months.nov','clock.months.dec'];
|
||||
const DAYS = ['So','Mo','Di','Mi','Do','Fr','Sa'];
|
||||
const MONTHS = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
|
||||
|
||||
function tick() {
|
||||
const now = new Date();
|
||||
document.getElementById('clock').textContent =
|
||||
`${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||||
document.getElementById('date').textContent =
|
||||
`${t(DAY_KEYS[now.getDay()])}, ${String(now.getDate()).padStart(2,'0')}. ${t(MONTH_KEYS[now.getMonth()])}`;
|
||||
`${DAYS[now.getDay()]}, ${String(now.getDate()).padStart(2,'0')}. ${MONTHS[now.getMonth()]}`;
|
||||
}
|
||||
tick();
|
||||
const clockInterval = setInterval(tick, 1000);
|
||||
setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
// ---- GLOBALE EVENTS (Header-Buttons, Modals, Import) ----
|
||||
@@ -149,7 +147,7 @@ function bindGlobalEvents() {
|
||||
if (!file) return;
|
||||
const imported = parseBookmarkHtml(await file.text());
|
||||
if (imported.length === 0) {
|
||||
await HellionDialog.alert(t('app.no_bookmarks'), { type: 'warning', title: t('app.import_title') });
|
||||
await HellionDialog.alert('Keine Bookmarks in dieser Datei gefunden.', { type: 'warning', title: 'Import' });
|
||||
return;
|
||||
}
|
||||
boards = [...boards, ...imported];
|
||||
@@ -157,8 +155,8 @@ function bindGlobalEvents() {
|
||||
renderBoards();
|
||||
e.target.value = '';
|
||||
await HellionDialog.alert(
|
||||
t('app.html_import_success', { count: imported.length, total: imported.reduce((s,b) => s + b.bookmarks.length, 0) }),
|
||||
{ type: 'success', title: t('app.import_success_title') }
|
||||
`${imported.length} Board(s) mit ${imported.reduce((s,b) => s + b.bookmarks.length, 0)} Bookmarks importiert.`,
|
||||
{ type: 'success', title: 'Import erfolgreich' }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -190,7 +188,7 @@ function bindGlobalEvents() {
|
||||
const url = document.getElementById('newBmUrl').value.trim();
|
||||
const desc = document.getElementById('newBmDesc').value.trim();
|
||||
if (!title || !url) return;
|
||||
try { new URL(url); } catch { await HellionDialog.alert(t('app.invalid_url'), { type: 'warning', title: t('app.invalid_url.title') }); 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);
|
||||
if (!board) return;
|
||||
board.bookmarks.push({ id: uid(), title, url, desc });
|
||||
|
||||
@@ -57,14 +57,14 @@ function renderBoards() {
|
||||
|
||||
const boardStrong = document.createElement('strong');
|
||||
boardStrong.className = 'accent-text';
|
||||
boardStrong.textContent = t('boards.add_board');
|
||||
boardStrong.textContent = '+ Board';
|
||||
|
||||
const importStrong = document.createElement('strong');
|
||||
importStrong.className = 'accent-text';
|
||||
importStrong.textContent = t('boards.import');
|
||||
importStrong.textContent = 'Import';
|
||||
|
||||
empty.append(
|
||||
t('boards.empty_state_pre'), boardStrong, t('boards.empty_state_mid'), importStrong, t('boards.empty_state_post')
|
||||
'No boards yet. Click ', boardStrong, ' to create one, or use ', importStrong, ' to load your browser bookmarks.'
|
||||
);
|
||||
wrapper.appendChild(empty);
|
||||
return;
|
||||
@@ -85,7 +85,7 @@ function createBoardEl(board) {
|
||||
|
||||
const dragHandle = document.createElement('span');
|
||||
dragHandle.className = 'board-drag-handle';
|
||||
dragHandle.title = t('boards.drag_title');
|
||||
dragHandle.title = 'Board verschieben';
|
||||
dragHandle.appendChild(createDragHandleSvg());
|
||||
|
||||
const titleSpanHeader = document.createElement('span');
|
||||
@@ -98,17 +98,17 @@ function createBoardEl(board) {
|
||||
|
||||
const btnBlur = document.createElement('button');
|
||||
btnBlur.className = 'board-action-btn btn-blur-board';
|
||||
btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur');
|
||||
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 = t('boards.rename');
|
||||
btnRename.title = 'Umbenennen';
|
||||
btnRename.textContent = '\u270E';
|
||||
|
||||
const btnDelete = document.createElement('button');
|
||||
btnDelete.className = 'board-action-btn btn-delete-board';
|
||||
btnDelete.title = t('boards.delete');
|
||||
btnDelete.title = 'Löschen';
|
||||
btnDelete.textContent = '\u2715';
|
||||
|
||||
actions.append(btnBlur, btnRename, btnDelete);
|
||||
@@ -123,14 +123,14 @@ function createBoardEl(board) {
|
||||
e.stopPropagation();
|
||||
board.blurred = !board.blurred;
|
||||
div.classList.toggle('blurred', board.blurred);
|
||||
btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur');
|
||||
btnBlur.title = board.blurred ? 'Unblur' : 'Blur (privat)';
|
||||
await saveBoards();
|
||||
});
|
||||
|
||||
blurOverlay.addEventListener('click', async () => {
|
||||
board.blurred = false;
|
||||
div.classList.remove('blurred');
|
||||
btnBlur.title = t('boards.blur');
|
||||
btnBlur.title = 'Blur (privat)';
|
||||
await saveBoards();
|
||||
});
|
||||
|
||||
@@ -147,8 +147,8 @@ function createBoardEl(board) {
|
||||
btnDelete.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const ok = await HellionDialog.confirm(
|
||||
t('boards.delete_confirm', { title: board.title }),
|
||||
{ type: 'danger', title: t('boards.delete_confirm.title'), confirmText: t('boards.delete') }
|
||||
`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);
|
||||
@@ -180,16 +180,16 @@ function createBoardEl(board) {
|
||||
let hiddenEls = [];
|
||||
const showMoreBtn = document.createElement('button');
|
||||
showMoreBtn.className = 'show-more-btn';
|
||||
showMoreBtn.textContent = t('boards.show_more', { count: hidden.length });
|
||||
showMoreBtn.textContent = `Show ${hidden.length} more…`;
|
||||
showMoreBtn.addEventListener('click', () => {
|
||||
if (!expanded) {
|
||||
hidden.forEach(bm => { const el = createBmEl(bm); hiddenEls.push(el); list.appendChild(el); });
|
||||
showMoreBtn.textContent = t('boards.show_less');
|
||||
showMoreBtn.textContent = 'Show less';
|
||||
expanded = true;
|
||||
} else {
|
||||
hiddenEls.forEach(el => el.remove());
|
||||
hiddenEls = [];
|
||||
showMoreBtn.textContent = t('boards.show_more', { count: hidden.length });
|
||||
showMoreBtn.textContent = `Show ${hidden.length} more…`;
|
||||
expanded = false;
|
||||
}
|
||||
});
|
||||
@@ -200,7 +200,7 @@ function createBoardEl(board) {
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'add-bm-btn';
|
||||
addBtn.appendChild(createPlusSvg());
|
||||
addBtn.append(t('boards.add_link'));
|
||||
addBtn.append(' Add link');
|
||||
addBtn.addEventListener('click', () => openAddBookmarkModal(board.id));
|
||||
div.appendChild(addBtn);
|
||||
|
||||
@@ -215,11 +215,19 @@ function createBmEl(bm) {
|
||||
li.dataset.bmUrl = bm.url;
|
||||
li.draggable = true;
|
||||
|
||||
const favicon = document.createElement('div');
|
||||
favicon.className = 'bm-favicon-local';
|
||||
favicon.textContent = bm.title.charAt(0).toUpperCase();
|
||||
const hue = (bm.title.charCodeAt(0) * 137) % 360;
|
||||
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
|
||||
const favicon = document.createElement('img');
|
||||
favicon.className = 'bm-favicon';
|
||||
favicon.width = 14;
|
||||
favicon.height = 14;
|
||||
favicon.src = getFaviconUrl(bm.url);
|
||||
favicon.addEventListener('error', function() {
|
||||
this.classList.add('hidden');
|
||||
this.nextElementSibling.classList.remove('hidden');
|
||||
});
|
||||
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'bm-favicon-fallback hidden';
|
||||
fallback.textContent = bm.title.charAt(0).toUpperCase();
|
||||
|
||||
const textDiv = document.createElement('div');
|
||||
textDiv.className = 'bm-text';
|
||||
@@ -235,10 +243,11 @@ function createBmEl(bm) {
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'bm-delete';
|
||||
deleteBtn.title = t('boards.remove_bookmark');
|
||||
deleteBtn.title = 'Entfernen';
|
||||
deleteBtn.textContent = '✕';
|
||||
|
||||
li.appendChild(favicon);
|
||||
li.appendChild(fallback);
|
||||
li.appendChild(textDiv);
|
||||
li.appendChild(deleteBtn);
|
||||
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — bookmark-import.js
|
||||
Direkt-Import von Browser-Lesezeichen
|
||||
via chrome.bookmarks.getTree() / browser.bookmarks.getTree()
|
||||
============================================= */
|
||||
|
||||
const BrowserBookmarkImport = {
|
||||
|
||||
/** Initialisiert den Import-Button */
|
||||
init() {
|
||||
const btn = document.getElementById('btnBrowserImport');
|
||||
const row = document.getElementById('browserImportRow');
|
||||
if (!btn || !row) return;
|
||||
|
||||
// API-Verfuegbarkeit pruefen (nicht vorhanden im normalen Browser-Tab)
|
||||
const api = this._getApi();
|
||||
if (!api) {
|
||||
row.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => this._openFolderModal());
|
||||
},
|
||||
|
||||
/**
|
||||
* Gibt die Bookmarks-API zurueck (Chrome oder Firefox)
|
||||
* @returns {object|null}
|
||||
*/
|
||||
_getApi() {
|
||||
if (typeof chrome !== 'undefined' && chrome.bookmarks) return chrome.bookmarks;
|
||||
if (typeof browser !== 'undefined' && browser.bookmarks) return browser.bookmarks;
|
||||
return null;
|
||||
},
|
||||
|
||||
/** Oeffnet das Ordner-Auswahl Modal */
|
||||
async _openFolderModal() {
|
||||
const api = this._getApi();
|
||||
if (!api) return;
|
||||
|
||||
let tree;
|
||||
try {
|
||||
tree = await api.getTree();
|
||||
} catch (err) {
|
||||
await HellionDialog.alert(
|
||||
t('bm_import.no_access'),
|
||||
{ type: 'warning', title: t('bm_import.title') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const folders = this._extractFolders(tree[0]);
|
||||
if (folders.length === 0) {
|
||||
await HellionDialog.alert(
|
||||
t('bm_import.no_folders'),
|
||||
{ type: 'warning', title: t('bm_import.title') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this._renderModal(folders);
|
||||
},
|
||||
|
||||
/**
|
||||
* Extrahiert alle Ordner rekursiv aus dem Bookmark-Baum
|
||||
* @param {object} node - Bookmark-Tree Node
|
||||
* @param {number} depth - Einrueckungstiefe
|
||||
* @returns {Array}
|
||||
*/
|
||||
_extractFolders(node, depth) {
|
||||
if (depth === undefined) depth = 0;
|
||||
const result = [];
|
||||
|
||||
if (!node.children) return result;
|
||||
|
||||
for (const child of node.children) {
|
||||
if (child.children) {
|
||||
const bookmarkCount = child.children.filter(function(c) { return c.url; }).length;
|
||||
const subfolderCount = child.children.filter(function(c) { return c.children; }).length;
|
||||
|
||||
result.push({
|
||||
id: child.id,
|
||||
title: child.title || t('bm_import.unnamed'),
|
||||
depth: depth,
|
||||
bookmarkCount: bookmarkCount,
|
||||
subfolderCount: subfolderCount,
|
||||
node: child
|
||||
});
|
||||
|
||||
const subFolders = this._extractFolders(child, depth + 1);
|
||||
for (const sf of subFolders) {
|
||||
result.push(sf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Rendert das Ordner-Auswahl Modal
|
||||
* @param {Array} folders - Liste der Ordner
|
||||
*/
|
||||
_renderModal(folders) {
|
||||
// Overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'bm-import-overlay';
|
||||
overlay.id = 'bmImportOverlay';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'bm-import-modal';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'bm-import-header';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.textContent = t('bm_import.modal_title');
|
||||
header.appendChild(title);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'bm-import-close';
|
||||
closeBtn.textContent = '\u00D7';
|
||||
closeBtn.addEventListener('click', () => this._closeModal());
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
modal.appendChild(header);
|
||||
|
||||
// Info
|
||||
const info = document.createElement('div');
|
||||
info.className = 'bm-import-info';
|
||||
info.textContent = t('bm_import.info');
|
||||
modal.appendChild(info);
|
||||
|
||||
// Ordner-Liste
|
||||
const list = document.createElement('div');
|
||||
list.className = 'bm-import-list';
|
||||
|
||||
for (const folder of folders) {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'bm-import-folder';
|
||||
row.style.paddingLeft = (12 + folder.depth * 20) + 'px';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'bm-import-checkbox';
|
||||
checkbox.dataset.folderId = folder.id;
|
||||
row.appendChild(checkbox);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'bm-import-folder-name';
|
||||
label.textContent = folder.title;
|
||||
row.appendChild(label);
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'bm-import-folder-meta';
|
||||
const parts = [];
|
||||
if (folder.bookmarkCount > 0) {
|
||||
parts.push(t('bm_import.link_count', { count: folder.bookmarkCount }));
|
||||
}
|
||||
if (folder.subfolderCount > 0) {
|
||||
parts.push(t('bm_import.folder_count', { count: folder.subfolderCount }));
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
parts.push(t('bm_import.empty'));
|
||||
}
|
||||
meta.textContent = parts.join(', ');
|
||||
row.appendChild(meta);
|
||||
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
modal.appendChild(list);
|
||||
|
||||
// Footer
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'bm-import-footer';
|
||||
|
||||
const selectAll = document.createElement('button');
|
||||
selectAll.className = 'btn-secondary';
|
||||
selectAll.textContent = t('bm_import.select_all');
|
||||
selectAll.addEventListener('click', () => {
|
||||
const boxes = list.querySelectorAll('.bm-import-checkbox');
|
||||
const allChecked = Array.from(boxes).every(function(cb) { return cb.checked; });
|
||||
boxes.forEach(function(cb) { cb.checked = !allChecked; });
|
||||
selectAll.textContent = allChecked ? t('bm_import.select_all') : t('bm_import.deselect_all');
|
||||
});
|
||||
footer.appendChild(selectAll);
|
||||
|
||||
const importBtn = document.createElement('button');
|
||||
importBtn.className = 'btn-primary';
|
||||
importBtn.textContent = t('bm_import.import_btn');
|
||||
importBtn.addEventListener('click', () => this._importSelected(folders));
|
||||
footer.appendChild(importBtn);
|
||||
|
||||
modal.appendChild(footer);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// 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(
|
||||
t('bm_import.no_selection'),
|
||||
{ type: 'warning', title: t('bm_import.title') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bestehende URLs sammeln fuer Duplikat-Erkennung
|
||||
const existingUrls = new Set();
|
||||
for (const board of boards) {
|
||||
for (const bm of board.bookmarks) {
|
||||
existingUrls.add(bm.url);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedIds = new Set();
|
||||
checkboxes.forEach(function(cb) { selectedIds.add(cb.dataset.folderId); });
|
||||
|
||||
let totalImported = 0;
|
||||
let totalSkipped = 0;
|
||||
let boardsCreated = 0;
|
||||
|
||||
for (const folder of folders) {
|
||||
if (!selectedIds.has(folder.id)) continue;
|
||||
|
||||
const bookmarks = [];
|
||||
for (const child of folder.node.children) {
|
||||
if (!child.url) continue;
|
||||
|
||||
// Nur http/https URLs
|
||||
try {
|
||||
const parsed = new URL(child.url);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Duplikat-Check
|
||||
if (existingUrls.has(child.url)) {
|
||||
totalSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
bookmarks.push({
|
||||
id: uid(),
|
||||
title: child.title || child.url,
|
||||
url: child.url,
|
||||
desc: ''
|
||||
});
|
||||
|
||||
existingUrls.add(child.url);
|
||||
totalImported++;
|
||||
}
|
||||
|
||||
if (bookmarks.length === 0) continue;
|
||||
|
||||
boards.push({
|
||||
id: uid(),
|
||||
title: folder.title,
|
||||
bookmarks: bookmarks,
|
||||
blurred: false
|
||||
});
|
||||
boardsCreated++;
|
||||
}
|
||||
|
||||
if (boardsCreated > 0) {
|
||||
await saveBoards();
|
||||
renderBoards();
|
||||
}
|
||||
|
||||
this._closeModal();
|
||||
|
||||
// Ergebnis-Dialog
|
||||
const lines = [];
|
||||
lines.push(t('bm_import.boards_created', { count: boardsCreated }));
|
||||
lines.push(t('bm_import.bookmarks_imported', { count: totalImported }));
|
||||
if (totalSkipped > 0) {
|
||||
lines.push(t('bm_import.duplicates_skipped', { count: totalSkipped }));
|
||||
}
|
||||
|
||||
await HellionDialog.alert(
|
||||
lines.join('\n'),
|
||||
{ type: 'success', title: t('bm_import.success_title') }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,344 +0,0 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — calc-converter.js
|
||||
Unit-Converter Modus für Calculator Widget
|
||||
============================================= */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const CATEGORIES = {
|
||||
length: {
|
||||
titleKey: 'calculator.conv.cat.length',
|
||||
baseUnit: 'm',
|
||||
units: {
|
||||
mm: { toBase: v => v / 1000, fromBase: v => v * 1000 },
|
||||
cm: { toBase: v => v / 100, fromBase: v => v * 100 },
|
||||
m: { toBase: v => v, fromBase: v => v },
|
||||
km: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||
in: { toBase: v => v * 0.0254, fromBase: v => v / 0.0254 },
|
||||
ft: { toBase: v => v * 0.3048, fromBase: v => v / 0.3048 },
|
||||
yd: { toBase: v => v * 0.9144, fromBase: v => v / 0.9144 },
|
||||
mi: { toBase: v => v * 1609.344, fromBase: v => v / 1609.344 }
|
||||
}
|
||||
},
|
||||
weight: {
|
||||
titleKey: 'calculator.conv.cat.weight',
|
||||
baseUnit: 'g',
|
||||
units: {
|
||||
mg: { toBase: v => v / 1000, fromBase: v => v * 1000 },
|
||||
g: { toBase: v => v, fromBase: v => v },
|
||||
kg: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||
t: { toBase: v => v * 1000000, fromBase: v => v / 1000000 },
|
||||
oz: { toBase: v => v * 28.3495, fromBase: v => v / 28.3495 },
|
||||
lb: { toBase: v => v * 453.592, fromBase: v => v / 453.592 }
|
||||
}
|
||||
},
|
||||
temperature: {
|
||||
titleKey: 'calculator.conv.cat.temperature',
|
||||
baseUnit: null,
|
||||
units: { '\u00B0C': null, '\u00B0F': null, 'K': null },
|
||||
convert(value, from, to) {
|
||||
if (from === to) return value;
|
||||
const key = from + '_' + to;
|
||||
const conversions = {
|
||||
'\u00B0C_\u00B0F': v => (v * 9 / 5) + 32,
|
||||
'\u00B0C_K': v => v + 273.15,
|
||||
'\u00B0F_\u00B0C': v => (v - 32) * 5 / 9,
|
||||
'\u00B0F_K': v => (v - 32) * 5 / 9 + 273.15,
|
||||
'K_\u00B0C': v => v - 273.15,
|
||||
'K_\u00B0F': v => (v - 273.15) * 9 / 5 + 32
|
||||
};
|
||||
const fn = conversions[key];
|
||||
return fn ? fn(value) : null;
|
||||
}
|
||||
},
|
||||
volume: {
|
||||
titleKey: 'calculator.conv.cat.volume',
|
||||
baseUnit: 'ml',
|
||||
units: {
|
||||
ml: { toBase: v => v, fromBase: v => v },
|
||||
L: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||
'm\u00B3':{ toBase: v => v * 1000000, fromBase: v => v / 1000000 },
|
||||
'gal(US)':{ toBase: v => v * 3785.41, fromBase: v => v / 3785.41 },
|
||||
'gal(UK)':{ toBase: v => v * 4546.09, fromBase: v => v / 4546.09 },
|
||||
'ft\u00B3':{ toBase: v => v * 28316.8, fromBase: v => v / 28316.8 }
|
||||
}
|
||||
},
|
||||
speed: {
|
||||
titleKey: 'calculator.conv.cat.speed',
|
||||
baseUnit: 'm/s',
|
||||
units: {
|
||||
'm/s': { toBase: v => v, fromBase: v => v },
|
||||
'km/h': { toBase: v => v / 3.6, fromBase: v => v * 3.6 },
|
||||
'mph': { toBase: v => v * 0.44704, fromBase: v => v / 0.44704 },
|
||||
'kn': { toBase: v => v * 0.514444, fromBase: v => v / 0.514444 }
|
||||
}
|
||||
},
|
||||
area: {
|
||||
titleKey: 'calculator.conv.cat.area',
|
||||
baseUnit: 'm\u00B2',
|
||||
units: {
|
||||
'mm\u00B2': { toBase: v => v / 1000000, fromBase: v => v * 1000000 },
|
||||
'cm\u00B2': { toBase: v => v / 10000, fromBase: v => v * 10000 },
|
||||
'm\u00B2': { toBase: v => v, fromBase: v => v },
|
||||
'km\u00B2': { toBase: v => v * 1000000, fromBase: v => v / 1000000 },
|
||||
'ha': { toBase: v => v * 10000, fromBase: v => v / 10000 },
|
||||
'acre': { toBase: v => v * 4046.86, fromBase: v => v / 4046.86 },
|
||||
'ft\u00B2': { toBase: v => v * 0.092903, fromBase: v => v / 0.092903 },
|
||||
'in\u00B2': { toBase: v => v * 0.00064516, fromBase: v => v / 0.00064516 }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = ['length', 'weight', 'temperature', 'volume', 'speed', 'area'];
|
||||
|
||||
let _currentCategory = 'length';
|
||||
let _fromUnit = 'cm';
|
||||
let _toUnit = 'in';
|
||||
let _fromInput = null;
|
||||
let _toInput = null;
|
||||
let _refEl = null;
|
||||
|
||||
/**
|
||||
* Converts a value from one unit to another within the current category.
|
||||
* @param {number} value
|
||||
* @param {string} from
|
||||
* @param {string} to
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function convert(value, from, to) {
|
||||
const cat = CATEGORIES[_currentCategory];
|
||||
if (!cat) return null;
|
||||
if (cat.convert) return cat.convert(value, from, to);
|
||||
const fromDef = cat.units[from];
|
||||
const toDef = cat.units[to];
|
||||
if (!fromDef || !toDef) return null;
|
||||
const base = fromDef.toBase(value);
|
||||
return toDef.fromBase(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculates the output field and reference lines based on current input.
|
||||
*/
|
||||
function recalc() {
|
||||
if (!_fromInput || !_toInput) return;
|
||||
const val = parseFloat(_fromInput.value);
|
||||
if (isNaN(val)) {
|
||||
_toInput.value = '';
|
||||
updateReference();
|
||||
return;
|
||||
}
|
||||
const result = convert(val, _fromUnit, _toUnit);
|
||||
if (result === null) {
|
||||
_toInput.value = '';
|
||||
} else {
|
||||
_toInput.value = Calculator._formatResult(result);
|
||||
}
|
||||
updateReference();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the reference conversion lines below the inputs.
|
||||
*/
|
||||
function updateReference() {
|
||||
if (!_refEl) return;
|
||||
_refEl.textContent = '';
|
||||
const r1 = convert(1, _fromUnit, _toUnit);
|
||||
const r2 = convert(1, _toUnit, _fromUnit);
|
||||
if (r1 !== null) {
|
||||
const line1 = document.createElement('div');
|
||||
line1.textContent = '1 ' + _fromUnit + ' = ' + Calculator._formatResult(r1) + ' ' + _toUnit;
|
||||
_refEl.appendChild(line1);
|
||||
}
|
||||
if (r2 !== null) {
|
||||
const line2 = document.createElement('div');
|
||||
line2.textContent = '1 ' + _toUnit + ' = ' + Calculator._formatResult(r2) + ' ' + _fromUnit;
|
||||
_refEl.appendChild(line2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates a unit <select> element with options for the current category.
|
||||
* @param {HTMLSelectElement} selectEl
|
||||
* @param {string} selectedUnit
|
||||
*/
|
||||
function populateUnitSelect(selectEl, selectedUnit) {
|
||||
while (selectEl.firstChild) {
|
||||
selectEl.removeChild(selectEl.firstChild);
|
||||
}
|
||||
const cat = CATEGORIES[_currentCategory];
|
||||
if (!cat) return;
|
||||
const units = Object.keys(cat.units);
|
||||
units.forEach(unit => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = unit;
|
||||
opt.textContent = unit;
|
||||
if (unit === selectedUnit) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sensible default from/to units for a given category key.
|
||||
* @param {string} catKey
|
||||
* @returns {{ from: string, to: string }}
|
||||
*/
|
||||
function getDefaultUnits(catKey) {
|
||||
const defaults = {
|
||||
length: { from: 'cm', to: 'in' },
|
||||
weight: { from: 'kg', to: 'lb' },
|
||||
temperature: { from: '\u00B0C', to: '\u00B0F' },
|
||||
volume: { from: 'L', to: 'gal(US)' },
|
||||
speed: { from: 'km/h', to: 'mph' },
|
||||
area: { from: 'm\u00B2', to: 'ft\u00B2' }
|
||||
};
|
||||
return defaults[catKey] || { from: Object.keys(CATEGORIES[catKey].units)[0], to: Object.keys(CATEGORIES[catKey].units)[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads persisted converter state from storage.
|
||||
*/
|
||||
async function loadState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||
if (data && data.calculator && data.calculator.converter) {
|
||||
const s = data.calculator.converter;
|
||||
if (s.lastCategory && CATEGORIES[s.lastCategory]) _currentCategory = s.lastCategory;
|
||||
if (s.fromUnit) _fromUnit = s.fromUnit;
|
||||
if (s.toUnit) _toUnit = s.toUnit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists current converter state to storage (read-before-write).
|
||||
*/
|
||||
async function saveState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||
if (!data.calculator) data.calculator = {};
|
||||
data.calculator.converter = {
|
||||
lastCategory: _currentCategory,
|
||||
fromUnit: _fromUnit,
|
||||
toUnit: _toUnit
|
||||
};
|
||||
await Store.set(Calculator.STORAGE_KEY, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the converter UI and appends it to the widget body element.
|
||||
* @param {HTMLElement} bodyEl
|
||||
*/
|
||||
function buildUI(bodyEl) {
|
||||
const catSelect = document.createElement('select');
|
||||
catSelect.className = 'calc-conv-select';
|
||||
|
||||
CATEGORY_ORDER.forEach(catKey => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = catKey;
|
||||
opt.textContent = t(CATEGORIES[catKey].titleKey);
|
||||
if (catKey === _currentCategory) opt.selected = true;
|
||||
catSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
const fromRow = document.createElement('div');
|
||||
fromRow.className = 'calc-conv-row';
|
||||
|
||||
_fromInput = document.createElement('input');
|
||||
_fromInput.type = 'number';
|
||||
_fromInput.className = 'calc-conv-input';
|
||||
_fromInput.placeholder = '0';
|
||||
_fromInput.step = 'any';
|
||||
|
||||
const fromSelect = document.createElement('select');
|
||||
fromSelect.className = 'calc-conv-unit';
|
||||
populateUnitSelect(fromSelect, _fromUnit);
|
||||
|
||||
fromRow.append(_fromInput, fromSelect);
|
||||
|
||||
const swapBtn = document.createElement('button');
|
||||
swapBtn.type = 'button';
|
||||
swapBtn.className = 'calc-conv-swap';
|
||||
swapBtn.textContent = '\u21C5';
|
||||
swapBtn.title = t('calculator.conv.swap');
|
||||
|
||||
const toRow = document.createElement('div');
|
||||
toRow.className = 'calc-conv-row';
|
||||
|
||||
_toInput = document.createElement('input');
|
||||
_toInput.type = 'text';
|
||||
_toInput.className = 'calc-conv-input';
|
||||
_toInput.readOnly = true;
|
||||
_toInput.placeholder = '0';
|
||||
|
||||
const toSelect = document.createElement('select');
|
||||
toSelect.className = 'calc-conv-unit';
|
||||
populateUnitSelect(toSelect, _toUnit);
|
||||
|
||||
toRow.append(_toInput, toSelect);
|
||||
|
||||
_refEl = document.createElement('div');
|
||||
_refEl.className = 'calc-conv-ref';
|
||||
|
||||
_fromInput.addEventListener('input', () => recalc());
|
||||
fromSelect.addEventListener('change', () => {
|
||||
_fromUnit = fromSelect.value;
|
||||
recalc();
|
||||
saveState();
|
||||
});
|
||||
toSelect.addEventListener('change', () => {
|
||||
_toUnit = toSelect.value;
|
||||
recalc();
|
||||
saveState();
|
||||
});
|
||||
swapBtn.addEventListener('click', () => {
|
||||
const tmpUnit = _fromUnit;
|
||||
_fromUnit = _toUnit;
|
||||
_toUnit = tmpUnit;
|
||||
populateUnitSelect(fromSelect, _fromUnit);
|
||||
populateUnitSelect(toSelect, _toUnit);
|
||||
const currentVal = _toInput.value;
|
||||
if (currentVal) {
|
||||
_fromInput.value = currentVal;
|
||||
}
|
||||
recalc();
|
||||
saveState();
|
||||
});
|
||||
catSelect.addEventListener('change', () => {
|
||||
_currentCategory = catSelect.value;
|
||||
const defaults = getDefaultUnits(_currentCategory);
|
||||
_fromUnit = defaults.from;
|
||||
_toUnit = defaults.to;
|
||||
populateUnitSelect(fromSelect, _fromUnit);
|
||||
populateUnitSelect(toSelect, _toUnit);
|
||||
_fromInput.value = '';
|
||||
_toInput.value = '';
|
||||
updateReference();
|
||||
saveState();
|
||||
});
|
||||
|
||||
bodyEl.append(catSelect, fromRow, swapBtn, toRow, _refEl);
|
||||
updateReference();
|
||||
}
|
||||
|
||||
Calculator.registerMode('converter', {
|
||||
label: '⚖️',
|
||||
shortName: 'Unit',
|
||||
titleKey: 'calculator.tab.converter',
|
||||
|
||||
render(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.gap = '8px';
|
||||
|
||||
loadState().then(() => {
|
||||
buildUI(bodyEl);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
_fromInput = null;
|
||||
_toInput = null;
|
||||
_refEl = null;
|
||||
saveState();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,247 +0,0 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — calc-factorio.js
|
||||
Factorio Calculator Modus
|
||||
============================================= */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const ASSEMBLERS = [
|
||||
{ key: 'asm1', speed: 0.5 },
|
||||
{ key: 'asm2', speed: 0.75 },
|
||||
{ key: 'asm3', speed: 1.25 }
|
||||
];
|
||||
|
||||
const BELTS = [
|
||||
{ key: 'yellow', throughput: 15, perSide: 7.5 },
|
||||
{ key: 'red', throughput: 30, perSide: 15 },
|
||||
{ key: 'blue', throughput: 45, perSide: 22.5 }
|
||||
];
|
||||
|
||||
const SUB_MODES = ['ratio', 'belt', 'machines'];
|
||||
let _activeSubMode = 'ratio';
|
||||
|
||||
function createAssemblerSelect(selectedKey) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = t('calculator.fac.assembler');
|
||||
const select = document.createElement('select');
|
||||
select.className = 'calc-game-input';
|
||||
ASSEMBLERS.forEach(asm => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = asm.key;
|
||||
opt.textContent = t('calculator.fac.asm.' + asm.key) + ' (' + asm.speed + 'x)';
|
||||
if (asm.key === selectedKey) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
row.append(label, select);
|
||||
return { row, select };
|
||||
}
|
||||
|
||||
function createBeltSelect(selectedKey) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = t('calculator.fac.belt');
|
||||
const select = document.createElement('select');
|
||||
select.className = 'calc-game-input';
|
||||
BELTS.forEach(belt => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = belt.key;
|
||||
opt.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + belt.throughput + '/s)';
|
||||
if (belt.key === selectedKey) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
row.append(label, select);
|
||||
return { row, select };
|
||||
}
|
||||
|
||||
function getAssemblerSpeed(key) {
|
||||
const asm = ASSEMBLERS.find(a => a.key === key);
|
||||
return asm ? asm.speed : 1;
|
||||
}
|
||||
|
||||
function getBelt(key) {
|
||||
return BELTS.find(b => b.key === key) || BELTS[0];
|
||||
}
|
||||
|
||||
function findSmallestBelt(throughput) {
|
||||
for (const belt of BELTS) {
|
||||
if (belt.throughput >= throughput) return belt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createField(labelKey, defaultVal, opts) {
|
||||
opts = opts || {};
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = t(labelKey);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'calc-game-input';
|
||||
input.value = defaultVal;
|
||||
if (opts.step) input.step = opts.step;
|
||||
if (opts.min !== undefined) input.min = opts.min;
|
||||
row.append(label, input);
|
||||
return { row, input };
|
||||
}
|
||||
|
||||
function createOutput(labelKey) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-output';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = t(labelKey);
|
||||
const value = document.createElement('span');
|
||||
value.className = 'calc-game-value';
|
||||
row.append(label, value);
|
||||
return { row, value };
|
||||
}
|
||||
|
||||
function renderRatio(container) {
|
||||
const asmSelect = createAssemblerSelect('asm3');
|
||||
const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
|
||||
const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });
|
||||
const perSecOutput = createOutput('calculator.fac.items_per_sec');
|
||||
const perMinOutput = createOutput('calculator.fac.items_per_min');
|
||||
|
||||
function calc() {
|
||||
const speed = getAssemblerSpeed(asmSelect.select.value);
|
||||
const output = parseFloat(outputField.input.value) || 0;
|
||||
const time = parseFloat(timeField.input.value) || 1;
|
||||
const perSec = output * speed / time;
|
||||
const perMin = perSec * 60;
|
||||
perSecOutput.value.textContent = Calculator._formatResult(perSec) + ' /s';
|
||||
perMinOutput.value.textContent = Calculator._formatResult(perMin) + ' /min';
|
||||
}
|
||||
|
||||
[outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
|
||||
asmSelect.select.addEventListener('change', calc);
|
||||
container.append(asmSelect.row, outputField.row, timeField.row, perSecOutput.row, perMinOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderBelt(container) {
|
||||
const beltSelect = createBeltSelect('yellow');
|
||||
const consumeField = createField('calculator.fac.consume_per_sec', 1, { step: 0.1, min: 0.1 });
|
||||
const machinesOutput = createOutput('calculator.fac.machines_per_belt');
|
||||
const utilOutput = createOutput('calculator.fac.belt_utilization');
|
||||
|
||||
function calc() {
|
||||
const belt = getBelt(beltSelect.select.value);
|
||||
const consume = parseFloat(consumeField.input.value) || 1;
|
||||
const machines = Math.floor(belt.throughput / consume);
|
||||
const util = (consume * machines) / belt.throughput * 100;
|
||||
machinesOutput.value.textContent = machines;
|
||||
utilOutput.value.textContent = Calculator._formatResult(util) + '%';
|
||||
}
|
||||
|
||||
consumeField.input.addEventListener('input', calc);
|
||||
beltSelect.select.addEventListener('change', calc);
|
||||
container.append(beltSelect.row, consumeField.row, machinesOutput.row, utilOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderMachines(container) {
|
||||
const asmSelect = createAssemblerSelect('asm3');
|
||||
const targetField = createField('calculator.fac.target_output_sec', 10, { step: 0.1, min: 0.1 });
|
||||
const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
|
||||
const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });
|
||||
const machinesOutput = createOutput('calculator.fac.machines_needed');
|
||||
const beltOutput = createOutput('calculator.fac.belt_needed');
|
||||
|
||||
function calc() {
|
||||
const speed = getAssemblerSpeed(asmSelect.select.value);
|
||||
const target = parseFloat(targetField.input.value) || 0;
|
||||
const output = parseFloat(outputField.input.value) || 1;
|
||||
const time = parseFloat(timeField.input.value) || 1;
|
||||
const perMachine = output * speed / time;
|
||||
const machines = perMachine > 0 ? Math.ceil(target / perMachine) : 0;
|
||||
const totalThroughput = machines * perMachine;
|
||||
const belt = findSmallestBelt(totalThroughput);
|
||||
|
||||
machinesOutput.value.textContent = machines;
|
||||
if (belt) {
|
||||
const util = (totalThroughput / belt.throughput) * 100;
|
||||
beltOutput.value.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + Calculator._formatResult(util) + '%)';
|
||||
} else {
|
||||
beltOutput.value.textContent = t('calculator.fac.exceeds_belt');
|
||||
}
|
||||
}
|
||||
|
||||
[targetField, outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
|
||||
asmSelect.select.addEventListener('change', calc);
|
||||
container.append(asmSelect.row, targetField.row, outputField.row, timeField.row, machinesOutput.row, beltOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||
if (data && data.calculator && data.calculator.factorio) {
|
||||
const s = data.calculator.factorio;
|
||||
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||
if (!data.calculator) data.calculator = {};
|
||||
data.calculator.factorio = { lastSubMode: _activeSubMode };
|
||||
await Store.set(Calculator.STORAGE_KEY, data);
|
||||
}
|
||||
|
||||
function renderSubMode(container) {
|
||||
container.textContent = '';
|
||||
switch (_activeSubMode) {
|
||||
case 'ratio': renderRatio(container); break;
|
||||
case 'belt': renderBelt(container); break;
|
||||
case 'machines': renderMachines(container); break;
|
||||
}
|
||||
}
|
||||
|
||||
Calculator.registerMode('factorio', {
|
||||
label: '🏭',
|
||||
shortName: 'FAC',
|
||||
titleKey: 'calculator.tab.factorio',
|
||||
|
||||
render(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.gap = '8px';
|
||||
|
||||
loadState().then(() => {
|
||||
const subContent = document.createElement('div');
|
||||
subContent.className = 'calc-game-content';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'calc-game-subtabs';
|
||||
|
||||
SUB_MODES.forEach(mode => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
|
||||
btn.textContent = t('calculator.fac.tab.' + mode);
|
||||
btn.dataset.mode = mode;
|
||||
btn.addEventListener('click', () => {
|
||||
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_activeSubMode = mode;
|
||||
renderSubMode(subContent);
|
||||
saveState();
|
||||
});
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
|
||||
bodyEl.append(bar, subContent);
|
||||
renderSubMode(subContent);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
saveState();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,184 +0,0 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — calc-satisfactory.js
|
||||
Satisfactory Calculator Modus
|
||||
============================================= */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const POWER_EXPONENT = 1.321928;
|
||||
const SUB_MODES = ['itemsPerMin', 'power', 'machines'];
|
||||
let _activeSubMode = 'itemsPerMin';
|
||||
|
||||
function createField(labelKey, defaultVal, opts) {
|
||||
opts = opts || {};
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = t(labelKey);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'calc-game-input';
|
||||
input.value = defaultVal;
|
||||
if (opts.step) input.step = opts.step;
|
||||
if (opts.min !== undefined) input.min = opts.min;
|
||||
if (opts.max !== undefined) input.max = opts.max;
|
||||
row.append(label, input);
|
||||
return { row, input };
|
||||
}
|
||||
|
||||
function createOutput(labelKey) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-output';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = t(labelKey);
|
||||
const value = document.createElement('span');
|
||||
value.className = 'calc-game-value';
|
||||
row.append(label, value);
|
||||
return { row, value };
|
||||
}
|
||||
|
||||
function renderItemsPerMin(container) {
|
||||
const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
|
||||
const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
|
||||
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
|
||||
const output = createOutput('calculator.sat.output_per_min');
|
||||
|
||||
function calc() {
|
||||
const items = parseFloat(itemsField.input.value) || 0;
|
||||
const time = parseFloat(timeField.input.value) || 1;
|
||||
const clock = parseFloat(clockField.input.value) || 100;
|
||||
const result = (items * 60) / time * (clock / 100);
|
||||
output.value.textContent = Calculator._formatResult(result) + ' items/min';
|
||||
}
|
||||
|
||||
[itemsField, timeField, clockField].forEach(f => f.input.addEventListener('input', calc));
|
||||
container.append(itemsField.row, timeField.row, clockField.row, output.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderPower(container) {
|
||||
const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });
|
||||
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
|
||||
const powerOutput = createOutput('calculator.sat.power_usage');
|
||||
const effOutput = createOutput('calculator.sat.efficiency');
|
||||
|
||||
function calc() {
|
||||
const basePower = parseFloat(basePowerField.input.value) || 0;
|
||||
const clock = parseFloat(clockField.input.value) || 100;
|
||||
const ratio = clock / 100;
|
||||
const power = basePower * Math.pow(ratio, POWER_EXPONENT);
|
||||
const effPerItem = Math.pow(ratio, POWER_EXPONENT - 1);
|
||||
|
||||
powerOutput.value.textContent = Calculator._formatResult(power) + ' MW';
|
||||
|
||||
if (clock > 100) {
|
||||
const overhead = (effPerItem - 1) * 100;
|
||||
effOutput.value.textContent = '+' + Calculator._formatResult(overhead) + '% ' + t('calculator.sat.per_item');
|
||||
effOutput.row.style.display = '';
|
||||
} else {
|
||||
effOutput.row.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
[basePowerField, clockField].forEach(f => f.input.addEventListener('input', calc));
|
||||
container.append(basePowerField.row, clockField.row, powerOutput.row, effOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderMachines(container) {
|
||||
const targetField = createField('calculator.sat.target_output', 60, { step: 1, min: 1 });
|
||||
const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
|
||||
const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
|
||||
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
|
||||
const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });
|
||||
const machinesOutput = createOutput('calculator.sat.machines_needed');
|
||||
const totalPowerOutput = createOutput('calculator.sat.total_power');
|
||||
|
||||
function calc() {
|
||||
const target = parseFloat(targetField.input.value) || 0;
|
||||
const items = parseFloat(itemsField.input.value) || 1;
|
||||
const time = parseFloat(timeField.input.value) || 1;
|
||||
const clock = parseFloat(clockField.input.value) || 100;
|
||||
const basePower = parseFloat(basePowerField.input.value) || 0;
|
||||
const ratio = clock / 100;
|
||||
const itemsPerMin = (items * 60) / time * ratio;
|
||||
const machines = itemsPerMin > 0 ? Math.ceil(target / itemsPerMin) : 0;
|
||||
const totalPower = machines * basePower * Math.pow(ratio, POWER_EXPONENT);
|
||||
machinesOutput.value.textContent = machines;
|
||||
totalPowerOutput.value.textContent = Calculator._formatResult(totalPower) + ' MW';
|
||||
}
|
||||
|
||||
[targetField, itemsField, timeField, clockField, basePowerField].forEach(f => f.input.addEventListener('input', calc));
|
||||
container.append(targetField.row, itemsField.row, timeField.row, clockField.row, basePowerField.row, machinesOutput.row, totalPowerOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||
if (data && data.calculator && data.calculator.satisfactory) {
|
||||
const s = data.calculator.satisfactory;
|
||||
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||
if (!data.calculator) data.calculator = {};
|
||||
data.calculator.satisfactory = { lastSubMode: _activeSubMode };
|
||||
await Store.set(Calculator.STORAGE_KEY, data);
|
||||
}
|
||||
|
||||
function renderSubMode(container) {
|
||||
container.textContent = '';
|
||||
switch (_activeSubMode) {
|
||||
case 'itemsPerMin': renderItemsPerMin(container); break;
|
||||
case 'power': renderPower(container); break;
|
||||
case 'machines': renderMachines(container); break;
|
||||
}
|
||||
}
|
||||
|
||||
Calculator.registerMode('satisfactory', {
|
||||
label: '⚙️',
|
||||
shortName: 'SAT',
|
||||
titleKey: 'calculator.tab.satisfactory',
|
||||
|
||||
render(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.gap = '8px';
|
||||
|
||||
loadState().then(() => {
|
||||
const subContent = document.createElement('div');
|
||||
subContent.className = 'calc-game-content';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'calc-game-subtabs';
|
||||
|
||||
SUB_MODES.forEach(mode => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
|
||||
btn.textContent = t('calculator.sat.tab.' + mode);
|
||||
btn.dataset.mode = mode;
|
||||
btn.addEventListener('click', () => {
|
||||
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_activeSubMode = mode;
|
||||
renderSubMode(subContent);
|
||||
saveState();
|
||||
});
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
|
||||
bodyEl.append(bar, subContent);
|
||||
renderSubMode(subContent);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
saveState();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,294 +0,0 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — calc-scientific.js
|
||||
Scientific-Modus für Calculator Widget
|
||||
============================================= */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const FORMULAS = [
|
||||
{
|
||||
key: 'circle_area',
|
||||
fields: [{ key: 'radius', default: '' }],
|
||||
calc: (vals) => Math.PI * vals.radius * vals.radius
|
||||
},
|
||||
{
|
||||
key: 'circle_circumference',
|
||||
fields: [{ key: 'radius', default: '' }],
|
||||
calc: (vals) => 2 * Math.PI * vals.radius
|
||||
},
|
||||
{
|
||||
key: 'celsius_to_fahrenheit',
|
||||
fields: [{ key: 'temp', default: '' }],
|
||||
calc: (vals) => (vals.temp * 9 / 5) + 32
|
||||
},
|
||||
{
|
||||
key: 'fahrenheit_to_celsius',
|
||||
fields: [{ key: 'temp', default: '' }],
|
||||
calc: (vals) => (vals.temp - 32) * 5 / 9
|
||||
},
|
||||
{
|
||||
key: 'pythagoras',
|
||||
fields: [{ key: 'a', default: '' }, { key: 'b', default: '' }],
|
||||
calc: (vals) => Math.sqrt(vals.a * vals.a + vals.b * vals.b)
|
||||
},
|
||||
{
|
||||
key: 'percentage',
|
||||
fields: [{ key: 'value', default: '' }, { key: 'percent', default: '' }],
|
||||
calc: (vals) => vals.value * vals.percent / 100
|
||||
}
|
||||
];
|
||||
|
||||
let _keyboardExtHandler = null;
|
||||
|
||||
function renderSciButtons(container) {
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'calc-buttons calc-sci-buttons';
|
||||
|
||||
const buttons = [
|
||||
['√', 'sqrt', 'operator'],
|
||||
['x²', 'square', 'operator'],
|
||||
['xⁿ', 'power', 'operator'],
|
||||
['π', 'pi', 'operator'],
|
||||
['e', 'euler', 'operator'],
|
||||
['±', 'negate', 'operator']
|
||||
];
|
||||
|
||||
buttons.forEach(([label, value, cls]) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'calc-btn' + (cls ? ' ' + cls : '');
|
||||
btn.textContent = label;
|
||||
btn.type = 'button';
|
||||
btn.addEventListener('click', () => handleSciKey(value));
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
|
||||
container.appendChild(grid);
|
||||
}
|
||||
|
||||
function handleSciKey(key) {
|
||||
switch (key) {
|
||||
case 'sqrt':
|
||||
if (!Calculator._currentExpr && Calculator._lastResult) {
|
||||
Calculator._currentExpr = 'sqrt(' + Calculator._lastResult + ')';
|
||||
Calculator._lastResult = '';
|
||||
Calculator._updateDisplay();
|
||||
break;
|
||||
}
|
||||
Calculator._currentExpr += 'sqrt(';
|
||||
Calculator._updateDisplay();
|
||||
break;
|
||||
case 'square':
|
||||
if (!Calculator._currentExpr && Calculator._lastResult) {
|
||||
Calculator._currentExpr = Calculator._lastResult;
|
||||
Calculator._lastResult = '';
|
||||
}
|
||||
Calculator._currentExpr += '^2';
|
||||
Calculator._updateDisplay();
|
||||
break;
|
||||
case 'power':
|
||||
Calculator._handleKey('^');
|
||||
break;
|
||||
case 'pi':
|
||||
Calculator._currentExpr += '3.14159265359';
|
||||
Calculator._updateDisplay();
|
||||
break;
|
||||
case 'euler':
|
||||
Calculator._currentExpr += '2.71828182846';
|
||||
Calculator._updateDisplay();
|
||||
break;
|
||||
case 'negate':
|
||||
handleNegate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNegate() {
|
||||
const expr = Calculator._currentExpr;
|
||||
if (!expr && Calculator._lastResult) {
|
||||
const num = parseFloat(Calculator._lastResult);
|
||||
if (!isNaN(num)) {
|
||||
Calculator._currentExpr = String(-num);
|
||||
Calculator._lastResult = '';
|
||||
Calculator._updateDisplay();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const match = expr.match(/(-?\d*\.?\d+)$/);
|
||||
if (match) {
|
||||
const num = parseFloat(match[1]);
|
||||
const negated = String(-num);
|
||||
Calculator._currentExpr = expr.slice(0, expr.length - match[1].length) + negated;
|
||||
Calculator._updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function renderFormulaHelper(container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'calc-formula-helper';
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'calc-formula-label';
|
||||
label.textContent = t('calculator.sci.formulas');
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.className = 'calc-formula-select';
|
||||
|
||||
const emptyOpt = document.createElement('option');
|
||||
emptyOpt.value = '';
|
||||
emptyOpt.textContent = t('calculator.sci.select_formula');
|
||||
select.appendChild(emptyOpt);
|
||||
|
||||
FORMULAS.forEach((f, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(i);
|
||||
opt.textContent = t('calculator.sci.formula.' + f.key);
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
const inputsContainer = document.createElement('div');
|
||||
inputsContainer.className = 'calc-formula-inputs';
|
||||
|
||||
const resultContainer = document.createElement('div');
|
||||
resultContainer.className = 'calc-formula-result';
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
while (inputsContainer.firstChild) {
|
||||
inputsContainer.removeChild(inputsContainer.firstChild);
|
||||
}
|
||||
resultContainer.textContent = '';
|
||||
|
||||
const idx = parseInt(select.value, 10);
|
||||
if (isNaN(idx)) return;
|
||||
|
||||
const formula = FORMULAS[idx];
|
||||
renderFormulaInputs(formula, inputsContainer, resultContainer);
|
||||
});
|
||||
|
||||
wrapper.append(label, select, inputsContainer, resultContainer);
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function renderFormulaInputs(formula, inputsEl, resultEl) {
|
||||
const inputs = {};
|
||||
|
||||
formula.fields.forEach(field => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-formula-row';
|
||||
|
||||
const lbl = document.createElement('label');
|
||||
lbl.textContent = t('calculator.sci.field.' + field.key);
|
||||
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'number';
|
||||
inp.className = 'calc-formula-input';
|
||||
inp.placeholder = '0';
|
||||
inp.step = 'any';
|
||||
inputs[field.key] = inp;
|
||||
|
||||
inp.addEventListener('input', () => {
|
||||
recalcFormula(formula, inputs, resultEl);
|
||||
});
|
||||
|
||||
row.append(lbl, inp);
|
||||
inputsEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function recalcFormula(formula, inputs, resultEl) {
|
||||
const vals = {};
|
||||
let allValid = true;
|
||||
|
||||
for (const field of formula.fields) {
|
||||
const v = parseFloat(inputs[field.key].value);
|
||||
if (isNaN(v)) { allValid = false; break; }
|
||||
vals[field.key] = v;
|
||||
}
|
||||
|
||||
if (!allValid) {
|
||||
resultEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const result = formula.calc(vals);
|
||||
if (result === null || !isFinite(result)) {
|
||||
resultEl.textContent = t('calculator.error');
|
||||
return;
|
||||
}
|
||||
|
||||
resultEl.textContent = '= ' + Calculator._formatResult(result);
|
||||
}
|
||||
|
||||
function bindSciKeyboard(widgetEl) {
|
||||
_keyboardExtHandler = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
if (e.target.contentEditable === 'true') return;
|
||||
|
||||
if (e.key === 'p') {
|
||||
handleSciKey('pi');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
} else if (e.key === '^') {
|
||||
handleSciKey('power');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
widgetEl.addEventListener('keydown', _keyboardExtHandler);
|
||||
}
|
||||
|
||||
Calculator.registerMode('scientific', {
|
||||
label: '📐',
|
||||
shortName: 'Sci',
|
||||
titleKey: 'calculator.tab.scientific',
|
||||
|
||||
render(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.flex = '1';
|
||||
bodyEl.style.overflow = 'hidden';
|
||||
|
||||
const display = document.createElement('div');
|
||||
display.className = 'calc-display';
|
||||
|
||||
const exprEl = document.createElement('div');
|
||||
exprEl.className = 'calc-expression';
|
||||
Calculator._displayExprEl = exprEl;
|
||||
|
||||
const resultEl = document.createElement('div');
|
||||
resultEl.className = 'calc-result';
|
||||
resultEl.textContent = Calculator._lastResult || '0';
|
||||
Calculator._displayResultEl = resultEl;
|
||||
|
||||
display.append(exprEl, resultEl);
|
||||
|
||||
const sciSection = document.createElement('div');
|
||||
renderSciButtons(sciSection);
|
||||
|
||||
const stdButtons = Calculator._createButtons();
|
||||
const historyEl = Calculator._createHistoryPanel();
|
||||
|
||||
const formulaSection = document.createElement('div');
|
||||
renderFormulaHelper(formulaSection);
|
||||
|
||||
bodyEl.append(display, sciSection, stdButtons, historyEl, formulaSection);
|
||||
Calculator._updateDisplay();
|
||||
|
||||
const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
|
||||
if (entry) bindSciKeyboard(entry.el);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (_keyboardExtHandler) {
|
||||
const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
|
||||
if (entry) {
|
||||
entry.el.removeEventListener('keydown', _keyboardExtHandler);
|
||||
}
|
||||
_keyboardExtHandler = null;
|
||||
}
|
||||
Calculator._displayExprEl = null;
|
||||
Calculator._displayResultEl = null;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,361 +0,0 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — calc-stationeers.js
|
||||
Stationeers Calculator Modus
|
||||
============================================= */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const R = 8314.46261815324;
|
||||
const COMBUSTION_ENERGY = 563452;
|
||||
const HEAT_CAP_PURE_FUEL = 61.9;
|
||||
const HEAT_CAP_DELTA = 172.615;
|
||||
const BATTERY_CAPACITY = 50000;
|
||||
|
||||
const HEAT_CAPS = [
|
||||
{ gas: 'O\u2082', cp: 21.1 },
|
||||
{ gas: 'H\u2082', cp: 20.4 },
|
||||
{ gas: 'CO\u2082', cp: 28.2 },
|
||||
{ gas: 'N\u2082', cp: 20.6 },
|
||||
{ gas: 'H\u2082O', cp: 72.0 },
|
||||
{ gas: 'N\u2082O', cp: 23.0 },
|
||||
{ gas: 'Pollutant', cp: 24.8 }
|
||||
];
|
||||
|
||||
const GAS_VARS = ['P', 'V', 'n', 'T'];
|
||||
const SUB_MODES = ['gas', 'furnace', 'solar', 'atmo'];
|
||||
let _activeSubMode = 'gas';
|
||||
|
||||
function createField(labelKey, defaultVal, opts) {
|
||||
opts = opts || {};
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = t(labelKey);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'calc-game-input';
|
||||
input.value = defaultVal;
|
||||
if (opts.step) input.step = opts.step;
|
||||
if (opts.min !== undefined) input.min = opts.min;
|
||||
if (opts.max !== undefined) input.max = opts.max;
|
||||
if (opts.disabled) input.disabled = true;
|
||||
row.append(label, input);
|
||||
return { row, input };
|
||||
}
|
||||
|
||||
function createOutput(labelKey) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-output';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = t(labelKey);
|
||||
const value = document.createElement('span');
|
||||
value.className = 'calc-game-value';
|
||||
row.append(label, value);
|
||||
return { row, value };
|
||||
}
|
||||
|
||||
function renderGas(container) {
|
||||
const solveRow = document.createElement('div');
|
||||
solveRow.className = 'calc-game-field';
|
||||
const solveLabel = document.createElement('label');
|
||||
solveLabel.textContent = t('calculator.sta.solve_for');
|
||||
const solveSelect = document.createElement('select');
|
||||
solveSelect.className = 'calc-game-input';
|
||||
|
||||
GAS_VARS.forEach(v => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v;
|
||||
opt.textContent = t('calculator.sta.var.' + v);
|
||||
solveSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
solveRow.append(solveLabel, solveSelect);
|
||||
container.appendChild(solveRow);
|
||||
|
||||
const fields = {};
|
||||
const defaults = { P: 101.325, V: 1000, n: 1, T: 293.15 };
|
||||
|
||||
GAS_VARS.forEach(v => {
|
||||
const f = createField(
|
||||
'calculator.sta.var.' + v + '_label',
|
||||
defaults[v],
|
||||
{ step: 'any' }
|
||||
);
|
||||
fields[v] = f;
|
||||
container.appendChild(f.row);
|
||||
});
|
||||
|
||||
const tempHelper = document.createElement('div');
|
||||
tempHelper.className = 'calc-game-hint';
|
||||
container.appendChild(tempHelper);
|
||||
|
||||
const resultOutput = createOutput('calculator.sta.result');
|
||||
container.appendChild(resultOutput.row);
|
||||
|
||||
function calc() {
|
||||
const solveFor = solveSelect.value;
|
||||
|
||||
GAS_VARS.forEach(v => {
|
||||
fields[v].input.disabled = (v === solveFor);
|
||||
fields[v].input.style.opacity = (v === solveFor) ? '0.5' : '1';
|
||||
});
|
||||
|
||||
const P_kPa = parseFloat(fields.P.input.value) || 0;
|
||||
const P = P_kPa * 1000;
|
||||
const V = parseFloat(fields.V.input.value) || 0;
|
||||
const n = parseFloat(fields.n.input.value) || 0;
|
||||
const T = parseFloat(fields.T.input.value) || 0;
|
||||
|
||||
let result = null;
|
||||
let unit = '';
|
||||
|
||||
switch (solveFor) {
|
||||
case 'P':
|
||||
if (V > 0) { result = (n * R * T) / V; result /= 1000; unit = 'kPa'; }
|
||||
break;
|
||||
case 'V':
|
||||
if (P > 0) { result = (n * R * T) / P; unit = 'L'; }
|
||||
break;
|
||||
case 'n':
|
||||
if (R * T > 0) { result = (P * V) / (R * T); unit = 'mol'; }
|
||||
break;
|
||||
case 'T':
|
||||
if (n * R > 0) { result = (P * V) / (n * R); unit = 'K'; }
|
||||
break;
|
||||
}
|
||||
|
||||
if (result !== null && isFinite(result)) {
|
||||
fields[solveFor].input.value = Calculator._formatResult(result);
|
||||
resultOutput.value.textContent = Calculator._formatResult(result) + ' ' + unit;
|
||||
} else {
|
||||
resultOutput.value.textContent = '-';
|
||||
}
|
||||
|
||||
const tempVal = parseFloat(fields.T.input.value) || 0;
|
||||
tempHelper.textContent = Calculator._formatResult(tempVal - 273.15) + ' \u00B0C';
|
||||
}
|
||||
|
||||
GAS_VARS.forEach(v => {
|
||||
fields[v].input.addEventListener('input', calc);
|
||||
});
|
||||
solveSelect.addEventListener('change', calc);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderFurnace(container) {
|
||||
const fuelField = createField('calculator.sta.fuel_ratio', 0.5, { step: 0.01, min: 0, max: 1 });
|
||||
const tempField = createField('calculator.sta.start_temp', 293.15, { step: 1, min: 0 });
|
||||
const pressField = createField('calculator.sta.start_pressure', 101.325, { step: 0.1, min: 0 });
|
||||
|
||||
const tempOutput = createOutput('calculator.sta.temp_after');
|
||||
const pressOutput = createOutput('calculator.sta.pressure_after');
|
||||
const warningEl = document.createElement('div');
|
||||
warningEl.className = 'calc-game-warning';
|
||||
|
||||
function calc() {
|
||||
const fuel = parseFloat(fuelField.input.value) || 0;
|
||||
const T_vor = parseFloat(tempField.input.value) || 293.15;
|
||||
const P_vor = parseFloat(pressField.input.value) || 101.325;
|
||||
|
||||
warningEl.textContent = '';
|
||||
if (fuel < 0.05) {
|
||||
warningEl.textContent = t('calculator.sta.warn_low_fuel');
|
||||
}
|
||||
if (P_vor < 10) {
|
||||
warningEl.textContent += (warningEl.textContent ? ' ' : '') + t('calculator.sta.warn_low_pressure');
|
||||
}
|
||||
|
||||
const specificHeat = HEAT_CAP_PURE_FUEL;
|
||||
const T_nach = (T_vor * specificHeat + fuel * COMBUSTION_ENERGY) / (specificHeat + fuel * HEAT_CAP_DELTA);
|
||||
const P_nach = P_vor * T_nach * (1 + 5.7 * fuel) / T_vor;
|
||||
|
||||
tempOutput.value.textContent = Calculator._formatResult(T_nach) + ' K (' + Calculator._formatResult(T_nach - 273.15) + ' \u00B0C)';
|
||||
pressOutput.value.textContent = Calculator._formatResult(P_nach) + ' kPa';
|
||||
}
|
||||
|
||||
[fuelField, tempField, pressField].forEach(f => f.input.addEventListener('input', calc));
|
||||
|
||||
container.append(fuelField.row, tempField.row, pressField.row, warningEl, tempOutput.row, pressOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderSolar(container) {
|
||||
const panelField = createField('calculator.sta.panels', 12, { step: 1, min: 1 });
|
||||
const wattField = createField('calculator.sta.watts_per_panel', 500, { step: 10, min: 1 });
|
||||
const dayField = createField('calculator.sta.day_length', 600, { step: 1, min: 1 });
|
||||
const nightField = createField('calculator.sta.night_length', 600, { step: 1, min: 1 });
|
||||
const consumeField = createField('calculator.sta.consumption', 2000, { step: 10, min: 0 });
|
||||
|
||||
const genOutput = createOutput('calculator.sta.generation');
|
||||
const surplusOutput = createOutput('calculator.sta.surplus');
|
||||
const nightOutput = createOutput('calculator.sta.night_energy');
|
||||
const battOutput = createOutput('calculator.sta.batteries_needed');
|
||||
|
||||
function calc() {
|
||||
const panels = parseFloat(panelField.input.value) || 0;
|
||||
const wpp = parseFloat(wattField.input.value) || 0;
|
||||
const nightLen = parseFloat(nightField.input.value) || 0;
|
||||
const consume = parseFloat(consumeField.input.value) || 0;
|
||||
|
||||
const generation = panels * wpp;
|
||||
const surplus = generation - consume;
|
||||
const nightEnergy = consume * nightLen;
|
||||
const batteries = nightEnergy > 0 ? Math.ceil(nightEnergy / BATTERY_CAPACITY) : 0;
|
||||
|
||||
genOutput.value.textContent = Calculator._formatResult(generation) + ' W';
|
||||
|
||||
surplusOutput.value.textContent = Calculator._formatResult(surplus) + ' W';
|
||||
if (surplus < 0) {
|
||||
surplusOutput.value.style.color = 'var(--danger)';
|
||||
} else {
|
||||
surplusOutput.value.style.color = '';
|
||||
}
|
||||
|
||||
nightOutput.value.textContent = Calculator._formatResult(nightEnergy) + ' Ws';
|
||||
battOutput.value.textContent = batteries;
|
||||
}
|
||||
|
||||
[panelField, wattField, dayField, nightField, consumeField].forEach(f => f.input.addEventListener('input', calc));
|
||||
|
||||
container.append(panelField.row, wattField.row, dayField.row, nightField.row, consumeField.row,
|
||||
genOutput.row, surplusOutput.row, nightOutput.row, battOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderAtmo(container) {
|
||||
const targetField = createField('calculator.sta.target_temp', 293.15, { step: 1 });
|
||||
const gas1Field = createField('calculator.sta.gas1_temp', 200, { step: 1 });
|
||||
const gas2Field = createField('calculator.sta.gas2_temp', 400, { step: 1 });
|
||||
|
||||
const m1Output = createOutput('calculator.sta.mixer_input1');
|
||||
const m2Output = createOutput('calculator.sta.mixer_input2');
|
||||
|
||||
function calc() {
|
||||
const T0 = parseFloat(targetField.input.value) || 0;
|
||||
const T1 = parseFloat(gas1Field.input.value) || 0;
|
||||
const T2 = parseFloat(gas2Field.input.value) || 0;
|
||||
|
||||
const denom = Math.abs(T1 - T0) + Math.abs(T2 - T0);
|
||||
if (denom === 0) {
|
||||
m1Output.value.textContent = '50%';
|
||||
m2Output.value.textContent = '50%';
|
||||
return;
|
||||
}
|
||||
|
||||
const M1 = Math.abs(T2 - T0) / denom;
|
||||
const M2 = 1 - M1;
|
||||
|
||||
m1Output.value.textContent = Calculator._formatResult(M1 * 100) + '%';
|
||||
m2Output.value.textContent = Calculator._formatResult(M2 * 100) + '%';
|
||||
}
|
||||
|
||||
[targetField, gas1Field, gas2Field].forEach(f => f.input.addEventListener('input', calc));
|
||||
|
||||
container.append(targetField.row, gas1Field.row, gas2Field.row, m1Output.row, m2Output.row);
|
||||
calc();
|
||||
|
||||
const details = document.createElement('details');
|
||||
details.className = 'calc-game-details';
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = t('calculator.sta.heat_cap_ref');
|
||||
details.appendChild(summary);
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'calc-game-table';
|
||||
|
||||
const thead = document.createElement('thead');
|
||||
const headerRow = document.createElement('tr');
|
||||
const thGas = document.createElement('th');
|
||||
thGas.textContent = t('calculator.sta.gas');
|
||||
const thCp = document.createElement('th');
|
||||
thCp.textContent = 'Cp (J/mol\u00B7K)';
|
||||
headerRow.append(thGas, thCp);
|
||||
thead.appendChild(headerRow);
|
||||
|
||||
const tbody = document.createElement('tbody');
|
||||
HEAT_CAPS.forEach(entry => {
|
||||
const tr = document.createElement('tr');
|
||||
const tdGas = document.createElement('td');
|
||||
tdGas.textContent = entry.gas;
|
||||
const tdCp = document.createElement('td');
|
||||
tdCp.textContent = entry.cp;
|
||||
tr.append(tdGas, tdCp);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.append(thead, tbody);
|
||||
details.appendChild(table);
|
||||
container.appendChild(details);
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||
if (data && data.calculator && data.calculator.stationeers) {
|
||||
const s = data.calculator.stationeers;
|
||||
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||
if (!data.calculator) data.calculator = {};
|
||||
data.calculator.stationeers = { lastSubMode: _activeSubMode };
|
||||
await Store.set(Calculator.STORAGE_KEY, data);
|
||||
}
|
||||
|
||||
function renderSubMode(container) {
|
||||
container.textContent = '';
|
||||
switch (_activeSubMode) {
|
||||
case 'gas': renderGas(container); break;
|
||||
case 'furnace': renderFurnace(container); break;
|
||||
case 'solar': renderSolar(container); break;
|
||||
case 'atmo': renderAtmo(container); break;
|
||||
}
|
||||
}
|
||||
|
||||
Calculator.registerMode('stationeers', {
|
||||
label: '\uD83D\uDE80',
|
||||
shortName: 'STA',
|
||||
titleKey: 'calculator.tab.stationeers',
|
||||
|
||||
render(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.gap = '8px';
|
||||
|
||||
loadState().then(() => {
|
||||
const subContent = document.createElement('div');
|
||||
subContent.className = 'calc-game-content';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'calc-game-subtabs';
|
||||
|
||||
SUB_MODES.forEach(mode => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
|
||||
btn.textContent = t('calculator.sta.tab.' + mode);
|
||||
btn.dataset.mode = mode;
|
||||
btn.addEventListener('click', () => {
|
||||
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_activeSubMode = mode;
|
||||
renderSubMode(subContent);
|
||||
saveState();
|
||||
});
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
|
||||
bodyEl.append(bar, subContent);
|
||||
renderSubMode(subContent);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
saveState();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -17,22 +17,6 @@ const Calculator = {
|
||||
_displayExprEl: null,
|
||||
_displayResultEl: null,
|
||||
_keydownHandler: null,
|
||||
_modes: new Map(),
|
||||
_activeMode: 'standard',
|
||||
_tabBarEl: null,
|
||||
|
||||
// ---- MODE REGISTRY ----
|
||||
|
||||
/**
|
||||
* Modus registrieren (wird von externen Mode-Dateien aufgerufen)
|
||||
* @param {string} name - Eindeutiger Modus-Name
|
||||
* @param {Object} config - { label, shortName, titleKey, render(bodyEl), destroy() }
|
||||
*/
|
||||
registerMode(name, config) {
|
||||
this._modes.set(name, config);
|
||||
// Tab-Bar aktualisieren falls Widget bereits offen
|
||||
if (this._tabBarEl) this._renderTabBar();
|
||||
},
|
||||
|
||||
// ---- STORAGE ----
|
||||
|
||||
@@ -43,9 +27,6 @@ const Calculator = {
|
||||
const data = await Store.get(this.STORAGE_KEY);
|
||||
if (data && data.calculator) {
|
||||
this._history = Array.isArray(data.calculator.history) ? data.calculator.history : [];
|
||||
if (data.calculator.activeMode) {
|
||||
this._activeMode = data.calculator.activeMode;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -55,19 +36,20 @@ const Calculator = {
|
||||
*/
|
||||
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);
|
||||
if (!data.calculator) data.calculator = {};
|
||||
data.calculator.x = widgetState ? widgetState.x : 400;
|
||||
data.calculator.y = widgetState ? widgetState.y : 120;
|
||||
data.calculator.width = widgetState ? widgetState.width : 280;
|
||||
data.calculator.height = widgetState ? widgetState.height : 400;
|
||||
data.calculator.open = this._isOpen;
|
||||
data.calculator.activeMode = this._activeMode;
|
||||
data.calculator.history = this._history.slice(0, this.MAX_HISTORY);
|
||||
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, data);
|
||||
await Store.set(this.STORAGE_KEY, { notes: notesState, calculator: calcData });
|
||||
},
|
||||
|
||||
// ---- WIDGET LIFECYCLE ----
|
||||
@@ -87,7 +69,7 @@ const Calculator = {
|
||||
|
||||
const widgetId = WidgetManager.create('calculator', {
|
||||
id: this.WIDGET_ID,
|
||||
title: t('calculator.title'),
|
||||
title: 'Taschenrechner',
|
||||
x: saved.x || 400,
|
||||
y: saved.y || 120,
|
||||
width: saved.width || 280,
|
||||
@@ -131,13 +113,8 @@ const Calculator = {
|
||||
* Wird aufgerufen wenn Widget geschlossen wird
|
||||
*/
|
||||
async onClose() {
|
||||
// Aktiven Modus aufräumen
|
||||
const mode = this._modes.get(this._activeMode);
|
||||
if (mode && mode.destroy) mode.destroy();
|
||||
|
||||
this._isOpen = false;
|
||||
this._unbindKeyboard();
|
||||
this._tabBarEl = null;
|
||||
this._displayExprEl = null;
|
||||
this._displayResultEl = null;
|
||||
await this.save();
|
||||
@@ -146,136 +123,14 @@ const Calculator = {
|
||||
// ---- UI RENDERING ----
|
||||
|
||||
/**
|
||||
* Calculator-Body rendern: Tab-Bar + aktiver Modus
|
||||
* Calculator-Body rendern (in Widget-Body einfuegen)
|
||||
* @param {HTMLElement} bodyEl
|
||||
*/
|
||||
renderBody(bodyEl) {
|
||||
bodyEl.textContent = '';
|
||||
bodyEl.style.padding = '0';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.height = '100%';
|
||||
|
||||
// Tab-Bar
|
||||
const tabBar = document.createElement('div');
|
||||
tabBar.className = 'calc-tab-bar';
|
||||
this._tabBarEl = tabBar;
|
||||
this._renderTabBar();
|
||||
|
||||
// Mode-Body Container
|
||||
const modeBody = document.createElement('div');
|
||||
modeBody.className = 'calc-mode-body';
|
||||
|
||||
bodyEl.append(tabBar, modeBody);
|
||||
|
||||
// Aktiven Modus rendern
|
||||
const mode = this._modes.get(this._activeMode);
|
||||
if (mode) {
|
||||
mode.render(modeBody);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Tab-Bar mit Buttons aus _modes Map befüllen
|
||||
*/
|
||||
_renderTabBar() {
|
||||
if (!this._tabBarEl) return;
|
||||
while (this._tabBarEl.firstChild) {
|
||||
this._tabBarEl.removeChild(this._tabBarEl.firstChild);
|
||||
}
|
||||
|
||||
this._modes.forEach((config, name) => {
|
||||
const tab = document.createElement('button');
|
||||
tab.type = 'button';
|
||||
tab.className = 'calc-tab' + (name === this._activeMode ? ' active' : '');
|
||||
tab.dataset.mode = name;
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'calc-tab-icon';
|
||||
icon.textContent = config.label;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'calc-tab-label';
|
||||
label.textContent = config.shortName;
|
||||
|
||||
tab.append(icon, label);
|
||||
tab.addEventListener('click', () => this.switchMode(name));
|
||||
this._tabBarEl.appendChild(tab);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Aktiven Tab visuell markieren (ohne Neuaufbau)
|
||||
*/
|
||||
_updateTabBar() {
|
||||
if (!this._tabBarEl) return;
|
||||
const tabs = this._tabBarEl.querySelectorAll('.calc-tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.mode === this._activeMode);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Modus wechseln
|
||||
* @param {string} name - Ziel-Modus
|
||||
*/
|
||||
async switchMode(name) {
|
||||
if (name === this._activeMode) return;
|
||||
const mode = this._modes.get(name);
|
||||
if (!mode) return;
|
||||
|
||||
// Alten Modus aufräumen
|
||||
const oldMode = this._modes.get(this._activeMode);
|
||||
if (oldMode && oldMode.destroy) oldMode.destroy();
|
||||
|
||||
this._activeMode = name;
|
||||
|
||||
// Mode-Body leeren und neu rendern
|
||||
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||
if (!entry) return;
|
||||
const modeBody = entry.el.querySelector('.calc-mode-body');
|
||||
if (!modeBody) return;
|
||||
modeBody.textContent = '';
|
||||
mode.render(modeBody);
|
||||
|
||||
// Tab-UI aktualisieren
|
||||
this._updateTabBar();
|
||||
|
||||
// Auto-Resize für komplexe Modi
|
||||
const isComplex = name !== 'standard';
|
||||
if (isComplex && entry) {
|
||||
const state = entry.state;
|
||||
if (state) {
|
||||
const newW = Math.max(state.width, 320);
|
||||
const newH = Math.max(state.height, 480);
|
||||
if (newW !== state.width || newH !== state.height) {
|
||||
entry.el.style.width = newW + 'px';
|
||||
entry.el.style.height = newH + 'px';
|
||||
state.width = newW;
|
||||
state.height = newH;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard neu binden
|
||||
this._unbindKeyboard();
|
||||
if (name === 'standard' || name === 'scientific') {
|
||||
if (entry) this._bindKeyboard(entry.el);
|
||||
}
|
||||
|
||||
await this.save();
|
||||
},
|
||||
|
||||
/**
|
||||
* Standard-Modus UI rendern
|
||||
* @param {HTMLElement} bodyEl
|
||||
*/
|
||||
_renderStandardMode(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.flex = '1';
|
||||
bodyEl.style.overflow = 'hidden';
|
||||
|
||||
// Display
|
||||
const display = document.createElement('div');
|
||||
@@ -359,7 +214,7 @@ const Calculator = {
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'calc-history-title';
|
||||
title.textContent = t('calculator.history');
|
||||
title.textContent = 'History';
|
||||
container.appendChild(title);
|
||||
|
||||
this._renderHistoryItems(container);
|
||||
@@ -442,8 +297,7 @@ const Calculator = {
|
||||
case '+':
|
||||
case '-':
|
||||
case '*':
|
||||
case '/':
|
||||
case '^': {
|
||||
case '/': {
|
||||
// Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen
|
||||
if (this._lastResult && this._currentExpr === '') {
|
||||
this._currentExpr = this._lastResult;
|
||||
@@ -451,7 +305,7 @@ const Calculator = {
|
||||
}
|
||||
// Doppelte Operatoren verhindern (letzten ersetzen)
|
||||
const last = this._currentExpr.slice(-1);
|
||||
if (/[+\-*/%^]/.test(last)) {
|
||||
if (/[+\-*/%]/.test(last)) {
|
||||
this._currentExpr = this._currentExpr.slice(0, -1) + key;
|
||||
} else {
|
||||
this._currentExpr += key;
|
||||
@@ -461,7 +315,7 @@ const Calculator = {
|
||||
|
||||
case '.': {
|
||||
// Doppelten Dezimalpunkt im letzten Zahlenblock verhindern
|
||||
const parts = this._currentExpr.split(/[+\-*/%()^]/);
|
||||
const parts = this._currentExpr.split(/[+\-*/%()]/);
|
||||
const lastPart = parts[parts.length - 1];
|
||||
if (lastPart && lastPart.includes('.')) break;
|
||||
this._currentExpr += key;
|
||||
@@ -491,7 +345,7 @@ const Calculator = {
|
||||
|
||||
const result = this._evaluate(this._currentExpr);
|
||||
if (result === null) {
|
||||
this._lastResult = t('calculator.error');
|
||||
this._lastResult = 'Fehler';
|
||||
this._updateDisplay();
|
||||
return;
|
||||
}
|
||||
@@ -527,7 +381,7 @@ const Calculator = {
|
||||
_evaluate(expr) {
|
||||
try {
|
||||
// Nur erlaubte Zeichen
|
||||
const sanitized = expr.replace(/[^0-9+\-*/.%()^a-z]/g, '');
|
||||
const sanitized = expr.replace(/[^0-9+\-*/.%()]/g, '');
|
||||
if (!sanitized) return null;
|
||||
|
||||
const tokens = this._tokenize(sanitized);
|
||||
@@ -551,13 +405,6 @@ const Calculator = {
|
||||
while (i < expr.length) {
|
||||
const ch = expr[i];
|
||||
|
||||
// Funktion: sqrt
|
||||
if (expr.substring(i, i + 4) === 'sqrt') {
|
||||
tokens.push({ type: 'func', value: 'sqrt' });
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Zahl (inkl. Dezimal)
|
||||
if (/[0-9.]/.test(ch)) {
|
||||
let num = '';
|
||||
@@ -596,13 +443,6 @@ const Calculator = {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Potenz-Operator
|
||||
if (ch === '^') {
|
||||
tokens.push({ type: 'op', value: '^' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Klammern
|
||||
if (ch === '(' || ch === ')') {
|
||||
tokens.push({ type: 'paren', value: ch });
|
||||
@@ -610,11 +450,6 @@ const Calculator = {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unbekannte Buchstaben
|
||||
if (/[a-z]/.test(ch)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unbekanntes Zeichen
|
||||
return null;
|
||||
}
|
||||
@@ -624,7 +459,6 @@ const Calculator = {
|
||||
|
||||
/**
|
||||
* Rekursiver Descent Parser mit Operator-Precedence
|
||||
* Hierarchie: parseExpr (+/-) → parseTerm (*\/%) → parsePower (^) → parseFactor
|
||||
* @param {Array} tokens
|
||||
* @returns {number|null}
|
||||
*/
|
||||
@@ -634,32 +468,36 @@ const Calculator = {
|
||||
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 tk = peek();
|
||||
if (!tk || tk.type !== 'op' || (tk.value !== '+' && tk.value !== '-')) break;
|
||||
const t = peek();
|
||||
if (!t || t.type !== 'op' || (t.value !== '+' && t.value !== '-')) break;
|
||||
consume();
|
||||
const right = parseTerm();
|
||||
if (right === null) return null;
|
||||
left = tk.value === '+' ? left + right : left - right;
|
||||
left = t.value === '+' ? left + right : left - right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
// Term: Factor (('*' | '/' | '%') Factor)*
|
||||
function parseTerm() {
|
||||
let left = parsePower();
|
||||
let left = parseFactor();
|
||||
if (left === null) return null;
|
||||
|
||||
while (pos < tokens.length) {
|
||||
const tk = peek();
|
||||
if (!tk || tk.type !== 'op' || (tk.value !== '*' && tk.value !== '/' && tk.value !== '%')) break;
|
||||
const t = peek();
|
||||
if (!t || t.type !== 'op' || (t.value !== '*' && t.value !== '/' && t.value !== '%')) break;
|
||||
consume();
|
||||
const right = parsePower();
|
||||
const right = parseFactor();
|
||||
if (right === null) return null;
|
||||
if (tk.value === '*') {
|
||||
if (t.value === '*') {
|
||||
left = left * right;
|
||||
} else if (tk.value === '/') {
|
||||
} else if (t.value === '/') {
|
||||
if (right === 0) return null;
|
||||
left = left / right;
|
||||
} else {
|
||||
@@ -669,51 +507,17 @@ const Calculator = {
|
||||
return left;
|
||||
}
|
||||
|
||||
// Power: Factor ('^' Power)? — rechts-assoziativ via Rekursion
|
||||
function parsePower() {
|
||||
let base = parseFactor();
|
||||
if (base === null) return null;
|
||||
const tk = peek();
|
||||
if (tk && tk.type === 'op' && tk.value === '^') {
|
||||
consume();
|
||||
const exp = parsePower(); // Rechts-assoziativ!
|
||||
if (exp === null) return null;
|
||||
return Math.pow(base, exp);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// Factor: func '(' Expression ')' | Number | '(' Expression ')'
|
||||
// Factor: Number | '(' Expression ')'
|
||||
function parseFactor() {
|
||||
const tk = peek();
|
||||
if (!tk) return null;
|
||||
const t = peek();
|
||||
if (!t) return null;
|
||||
|
||||
// Funktion: sqrt(...)
|
||||
if (tk.type === 'func') {
|
||||
const funcName = tk.value;
|
||||
if (t.type === 'number') {
|
||||
consume();
|
||||
const open = peek();
|
||||
if (!open || open.type !== 'paren' || open.value !== '(') return null;
|
||||
consume();
|
||||
const val = parseExpr();
|
||||
if (val === null) return null;
|
||||
const close = peek();
|
||||
if (close && close.type === 'paren' && close.value === ')') {
|
||||
consume();
|
||||
}
|
||||
if (funcName === 'sqrt') {
|
||||
if (val < 0) return null; // Negativer Radikand nicht erlaubt
|
||||
return Math.sqrt(val);
|
||||
}
|
||||
return null;
|
||||
return t.value;
|
||||
}
|
||||
|
||||
if (tk.type === 'number') {
|
||||
consume();
|
||||
return tk.value;
|
||||
}
|
||||
|
||||
if (tk.type === 'paren' && tk.value === '(') {
|
||||
if (t.type === 'paren' && t.value === '(') {
|
||||
consume();
|
||||
const val = parseExpr();
|
||||
if (val === null) return null;
|
||||
@@ -758,8 +562,7 @@ const Calculator = {
|
||||
_formatExpression(expr) {
|
||||
return expr
|
||||
.replace(/\*/g, '\u00D7')
|
||||
.replace(/\//g, '\u00F7')
|
||||
.replace(/sqrt\(/g, '\u221A(');
|
||||
.replace(/\//g, '\u00F7');
|
||||
},
|
||||
|
||||
// ---- DISPLAY ----
|
||||
@@ -880,50 +683,47 @@ const Calculator = {
|
||||
async init() {
|
||||
await this.load();
|
||||
|
||||
// Standard-Modus ZUERST registrieren, bevor open() aufgerufen wird
|
||||
this._modes.set('standard', {
|
||||
label: '🔢',
|
||||
shortName: 'Std',
|
||||
titleKey: 'calculator.tab.standard',
|
||||
render: (bodyEl) => this._renderStandardMode(bodyEl),
|
||||
destroy: () => {
|
||||
this._displayExprEl = null;
|
||||
this._displayResultEl = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Wenn Calculator beim letzten Mal offen war, wiederherstellen
|
||||
const data = await Store.get(this.STORAGE_KEY);
|
||||
if (data && data.calculator && data.calculator.open) {
|
||||
await this.open();
|
||||
}
|
||||
|
||||
// Widget-Lifecycle-Events
|
||||
// Close-Event abfangen: WidgetManager.close() ueberschreiben
|
||||
const origClose = WidgetManager.close.bind(WidgetManager);
|
||||
const self = this;
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
WidgetManager.close = function(id) {
|
||||
origClose(id);
|
||||
if (id === self.WIDGET_ID) {
|
||||
self.onClose();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
// 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;
|
||||
self.save();
|
||||
await self.save();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
// 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);
|
||||
self.save();
|
||||
await self.save();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,26 +9,11 @@ function initDataButtons() {
|
||||
const jsonInput = document.getElementById('jsonImportInput');
|
||||
if (!btnExport || !btnImport) return;
|
||||
|
||||
/**
|
||||
* Prueft ob eine URL ein sicheres Protokoll hat.
|
||||
* Blockiert javascript:, data:, vbscript: etc.
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSafeUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Export (inkl. Notes)
|
||||
btnExport.addEventListener('click', async () => {
|
||||
const widgetData = await Store.get('widgetStates');
|
||||
const data = {
|
||||
version: '2.1.0',
|
||||
version: '1.7.0',
|
||||
exported: new Date().toISOString(),
|
||||
boards,
|
||||
settings,
|
||||
@@ -52,26 +37,23 @@ function initDataButtons() {
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format'));
|
||||
const validBoards = data.boards
|
||||
.filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
|
||||
.map(b => ({
|
||||
id: b.id || uid(),
|
||||
title: String(b.title).slice(0, 100),
|
||||
blurred: !!b.blurred,
|
||||
bookmarks: b.bookmarks
|
||||
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
|
||||
.map(bm => ({
|
||||
id: bm.id || uid(),
|
||||
title: String(bm.title).slice(0, 200),
|
||||
url: bm.url,
|
||||
desc: String(bm.desc || '').slice(0, 500)
|
||||
}))
|
||||
}));
|
||||
if (validBoards.length === 0) throw new Error(t('data.no_boards'));
|
||||
if (!Array.isArray(data.boards)) throw new Error('Ungültiges Format');
|
||||
const validBoards = data.boards.filter(b => {
|
||||
if (!b || typeof b.title !== 'string' || !Array.isArray(b.bookmarks)) return false;
|
||||
b.id = b.id || uid();
|
||||
b.blurred = !!b.blurred;
|
||||
b.bookmarks = b.bookmarks.filter(bm => {
|
||||
if (!bm || typeof bm.title !== 'string' || typeof bm.url !== 'string') return false;
|
||||
bm.id = bm.id || uid();
|
||||
bm.desc = bm.desc || '';
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
if (validBoards.length === 0) throw new Error('Keine gültigen Boards gefunden');
|
||||
const ok = await HellionDialog.confirm(
|
||||
t('data.import_confirm', { count: validBoards.length }),
|
||||
{ type: 'info', title: t('data.import_confirm.title') }
|
||||
`${validBoards.length} Boards importieren? Bestehende Daten bleiben erhalten.`,
|
||||
{ type: 'info', title: 'JSON Import' }
|
||||
);
|
||||
if (!ok) return;
|
||||
boards = [...boards, ...validBoards];
|
||||
@@ -83,26 +65,18 @@ function initDataButtons() {
|
||||
const existingWidgets = await Store.get('widgetStates') || {};
|
||||
if (Array.isArray(data.notes) && data.notes.length > 0) {
|
||||
const existingNotes = Array.isArray(existingWidgets.notes) ? existingWidgets.notes : [];
|
||||
const importNotes = data.notes
|
||||
.filter(n => n && n.id && n.template)
|
||||
.map(n => ({
|
||||
id: n.id,
|
||||
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
|
||||
title: String(n.title || '').slice(0, 200),
|
||||
content: String(n.content || '').slice(0, 5000),
|
||||
x: typeof n.x === 'number' ? n.x : 120,
|
||||
y: typeof n.y === 'number' ? n.y : 80,
|
||||
width: typeof n.width === 'number' ? n.width : 280,
|
||||
height: typeof n.height === 'number' ? n.height : 220,
|
||||
open: n.open !== false,
|
||||
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
|
||||
}));
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -116,6 +90,7 @@ function initDataButtons() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -129,6 +104,7 @@ function initDataButtons() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -136,20 +112,15 @@ function initDataButtons() {
|
||||
// Gemeinsam speichern
|
||||
await Store.set('widgetStates', existingWidgets);
|
||||
|
||||
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
|
||||
if (notesImported > 0) await Notes.init();
|
||||
if (calcImported) await Calculator.load();
|
||||
if (timerImported) await Timer.load();
|
||||
|
||||
const noteMsg = notesImported > 0 ? t('data.notes_suffix', { count: notesImported }) : '';
|
||||
const calcMsg = calcImported ? t('data.calc_suffix') : '';
|
||||
const timerMsg = timerImported ? t('data.timer_suffix') : '';
|
||||
const noteMsg = notesImported > 0 ? ` + ${notesImported} Note(s)` : '';
|
||||
const calcMsg = calcImported ? ' + Calculator-History' : '';
|
||||
const timerMsg = timerImported ? ' + Timer-Presets' : '';
|
||||
await HellionDialog.alert(
|
||||
t('data.import_success', { boards: validBoards.length, notes: noteMsg, calc: calcMsg, timer: timerMsg }),
|
||||
{ type: 'success', title: t('data.import_success.title') }
|
||||
`${validBoards.length} Board(s)${noteMsg}${calcMsg}${timerMsg} erfolgreich importiert.`,
|
||||
{ type: 'success', title: 'Import erfolgreich' }
|
||||
);
|
||||
} catch (err) {
|
||||
await HellionDialog.alert(t('data.import_error', { error: err.message }), { type: 'danger', title: t('data.import_error.title') });
|
||||
await HellionDialog.alert('Fehler beim Import: ' + err.message, { type: 'danger', title: 'Import fehlgeschlagen' });
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
@@ -126,8 +126,8 @@ const HellionDialog = {
|
||||
const opts = options || {};
|
||||
return this._show({
|
||||
message,
|
||||
title: opts.title || t('dialog.default_title'),
|
||||
confirmText: opts.confirmText || t('dialog.ok'),
|
||||
title: opts.title || 'Hinweis',
|
||||
confirmText: opts.confirmText || 'OK',
|
||||
cancelText: '',
|
||||
type: opts.type || 'info',
|
||||
isConfirm: false
|
||||
@@ -144,9 +144,9 @@ const HellionDialog = {
|
||||
const opts = options || {};
|
||||
return this._show({
|
||||
message,
|
||||
title: opts.title || t('dialog.confirm_title'),
|
||||
confirmText: opts.confirmText || t('dialog.ok'),
|
||||
cancelText: opts.cancelText || t('dialog.cancel'),
|
||||
title: opts.title || 'Bestätigung',
|
||||
confirmText: opts.confirmText || 'OK',
|
||||
cancelText: opts.cancelText || 'Abbrechen',
|
||||
type: opts.type || 'info',
|
||||
isConfirm: true
|
||||
});
|
||||
|
||||
@@ -1,910 +0,0 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — i18n.js
|
||||
Internationalisierung: DE/EN Sprachumschaltung
|
||||
============================================= */
|
||||
|
||||
const STRINGS = {
|
||||
de: {
|
||||
// Dialog-System
|
||||
'dialog.default_title': 'Hinweis',
|
||||
'dialog.ok': 'OK',
|
||||
'dialog.confirm_title': 'Bestätigung',
|
||||
'dialog.cancel': 'Abbrechen',
|
||||
'dialog.close': 'Schließen',
|
||||
|
||||
// Boards
|
||||
'boards.empty_state_pre': 'Noch keine Boards. Klicke auf ',
|
||||
'boards.add_board': '+ Board',
|
||||
'boards.empty_state_mid': ' um eins zu erstellen, oder nutze ',
|
||||
'boards.import': 'Import',
|
||||
'boards.empty_state_post': ' um deine Browser-Lesezeichen zu laden.',
|
||||
'boards.drag_title': 'Board verschieben',
|
||||
'boards.blur': 'Blur (privat)',
|
||||
'boards.unblur': 'Unblur',
|
||||
'boards.rename': 'Umbenennen',
|
||||
'boards.delete': 'Löschen',
|
||||
'boards.delete_confirm': 'Board „{title}" wirklich löschen?',
|
||||
'boards.delete_confirm.title': 'Board löschen',
|
||||
'boards.show_more': '{count} weitere anzeigen…',
|
||||
'boards.show_less': 'Weniger anzeigen',
|
||||
'boards.add_link': ' Link hinzufügen',
|
||||
'boards.remove_bookmark': 'Entfernen',
|
||||
|
||||
// Onboarding
|
||||
'onboarding.skip': 'Überspringen',
|
||||
'onboarding.back': 'Zurück',
|
||||
'onboarding.next': 'Weiter',
|
||||
'onboarding.start': 'Los geht\'s!',
|
||||
'onboarding.yes': 'Ja, gerne',
|
||||
'onboarding.no': 'Nein danke',
|
||||
'onboarding.s1.title': 'Willkommen bei Hellion Dashboard',
|
||||
'onboarding.s1.text': 'Dein neuer Browser-Startbildschirm. Minimalistisch, schnell und vollständig lokal — keine Cloud, kein Account, keine Datensammlung.',
|
||||
'onboarding.s2.title': 'Boards & Bookmarks',
|
||||
'onboarding.s2.f1': 'Erstelle Boards mit dem „+ Board" Button oben',
|
||||
'onboarding.s2.f2': 'Importiere Browser-Lesezeichen über den „Import" Button im Header',
|
||||
'onboarding.s2.f3': 'Drag & Drop zum Umsortieren von Boards und Links',
|
||||
'onboarding.s2.f4': 'Blur-Modus für private Boards (🔒 Icon)',
|
||||
'onboarding.s3.title': '11 handgefertigte Themes',
|
||||
'onboarding.s3.text': 'Klicke auf den „Theme" Button im Header um dein Theme zu wählen. Jedes hat seinen eigenen Stil und Farbpalette.',
|
||||
'onboarding.s4.title': 'Widget-Toolbar',
|
||||
'onboarding.s4.f1': 'Die schwebenden Buttons rechts öffnen Widgets',
|
||||
'onboarding.s4.f2': 'Notes und Checklisten für schnelle Notizen',
|
||||
'onboarding.s4.f3': 'Taschenrechner mit History',
|
||||
'onboarding.s4.f4': 'Timer/Countdown mit speicherbaren Presets',
|
||||
'onboarding.s4.f5': 'Bild-Referenz Widgets (aktivierbar in Settings)',
|
||||
'onboarding.s4.f6': 'Notebook-Sidebar zeigt alle Notes auf einen Blick',
|
||||
'onboarding.s5.title': 'Backups nicht vergessen!',
|
||||
'onboarding.s5.text': 'Deine Daten sind lokal im Browser gespeichert. Wenn du Browserdaten löschst, gehen sie verloren! Sichere regelmäßig über Settings → Data → Export. Wir erinnern dich alle 7 Tage daran.',
|
||||
'onboarding.s6.title': 'Gaming Starter Board',
|
||||
'onboarding.s6.text': 'Spielst du Games wie Satisfactory, Factorio oder Star Citizen? Ich kann ein Board mit nützlichen Community-Links anlegen.',
|
||||
'onboarding.tradecenter_desc': 'Trade Center für Star Citizen',
|
||||
'onboarding.s7.title': 'Bereit!',
|
||||
'onboarding.s7.text': 'Erstelle dein erstes Board mit „+ Board" oder importiere deine Browser-Lesezeichen über den Import-Button im Header. Viel Spaß!',
|
||||
|
||||
// Notes
|
||||
'notes.limit_message': 'Maximale Anzahl erreicht! Du kannst maximal {max} Notes gleichzeitig haben. Lösche eine bestehende Note um eine neue zu erstellen.',
|
||||
'notes.limit_title': 'Limit erreicht',
|
||||
'notes.checklist_title': 'Checkliste',
|
||||
'notes.default_title': 'Note',
|
||||
'notes.placeholder': 'Notiz schreiben...',
|
||||
'notes.checklist_placeholder': 'Neues Item...',
|
||||
'notes.delete_confirm': 'Note endgültig löschen? Das kann nicht rückgängig gemacht werden.',
|
||||
'notes.delete_title': 'Note löschen',
|
||||
'notes.delete_button': 'Löschen',
|
||||
'notes.checklist_progress': '{done}/{total} erledigt',
|
||||
'notes.empty_preview': 'Leer',
|
||||
'notes.export': 'Export',
|
||||
'notes.export_footer': 'Exportiert aus Hellion Dashboard',
|
||||
'notes.create': '+ Note erstellen',
|
||||
'notes.text_type': '✎ Freitext',
|
||||
'notes.checklist_type': '☑ Checkliste',
|
||||
|
||||
// Calculator
|
||||
'calculator.title': 'Taschenrechner',
|
||||
'calculator.history': 'History',
|
||||
'calculator.error': 'Fehler',
|
||||
'calculator.tab.standard': 'Standard',
|
||||
'calculator.tab.scientific': 'Wissenschaftlich',
|
||||
'calculator.sci.formulas': 'Formel-Helfer',
|
||||
'calculator.sci.select_formula': 'Formel wählen…',
|
||||
'calculator.sci.formula.circle_area': 'Kreisfläche (π×r²)',
|
||||
'calculator.sci.formula.circle_circumference':'Kreisumfang (2πr)',
|
||||
'calculator.sci.formula.celsius_to_fahrenheit':'°C → °F',
|
||||
'calculator.sci.formula.fahrenheit_to_celsius':'°F → °C',
|
||||
'calculator.sci.formula.pythagoras': 'Pythagoras (√(a²+b²))',
|
||||
'calculator.sci.formula.percentage': 'Prozentwert',
|
||||
'calculator.sci.field.radius': 'Radius',
|
||||
'calculator.sci.field.temp': 'Temperatur',
|
||||
'calculator.sci.field.a': 'Seite a',
|
||||
'calculator.sci.field.b': 'Seite b',
|
||||
'calculator.sci.field.value': 'Wert',
|
||||
'calculator.sci.field.percent': 'Prozent',
|
||||
'calculator.tab.converter': 'Umrechner',
|
||||
'calculator.conv.swap': 'Einheiten tauschen',
|
||||
'calculator.conv.cat.length': 'Länge',
|
||||
'calculator.conv.cat.weight': 'Gewicht',
|
||||
'calculator.conv.cat.temperature': 'Temperatur',
|
||||
'calculator.conv.cat.volume': 'Volumen',
|
||||
'calculator.conv.cat.speed': 'Geschwindigkeit',
|
||||
'calculator.conv.cat.area': 'Fläche',
|
||||
'calculator.tab.satisfactory': 'Satisfactory',
|
||||
'calculator.sat.tab.itemsPerMin': 'Items/Min',
|
||||
'calculator.sat.tab.power': 'Strom',
|
||||
'calculator.sat.tab.machines': 'Maschinen',
|
||||
'calculator.sat.items_per_craft': 'Items/Craft',
|
||||
'calculator.sat.craft_time': 'Craftzeit (s)',
|
||||
'calculator.sat.clock_speed': 'Taktrate (%)',
|
||||
'calculator.sat.base_power': 'Grundleistung (MW)',
|
||||
'calculator.sat.target_output': 'Ziel Output/Min',
|
||||
'calculator.sat.output_per_min': 'Output',
|
||||
'calculator.sat.power_usage': 'Stromverbrauch',
|
||||
'calculator.sat.efficiency': 'Effizienz',
|
||||
'calculator.sat.per_item': 'pro Item',
|
||||
'calculator.sat.machines_needed': 'Maschinen benötigt',
|
||||
'calculator.sat.total_power': 'Gesamtleistung',
|
||||
|
||||
// Factorio Calculator
|
||||
'calculator.tab.factorio': 'Factorio',
|
||||
'calculator.fac.tab.ratio': 'Ratio',
|
||||
'calculator.fac.tab.belt': 'Belt',
|
||||
'calculator.fac.tab.machines': 'Maschinen',
|
||||
'calculator.fac.assembler': 'Assembler',
|
||||
'calculator.fac.asm.asm1': 'Assembler 1',
|
||||
'calculator.fac.asm.asm2': 'Assembler 2',
|
||||
'calculator.fac.asm.asm3': 'Assembler 3',
|
||||
'calculator.fac.belt': 'Belt-Typ',
|
||||
'calculator.fac.belt.yellow': 'Gelb',
|
||||
'calculator.fac.belt.red': 'Rot',
|
||||
'calculator.fac.belt.blue': 'Blau',
|
||||
'calculator.fac.recipe_output': 'Rezept-Output',
|
||||
'calculator.fac.recipe_time': 'Rezeptzeit (s)',
|
||||
'calculator.fac.consume_per_sec': 'Verbrauch/s',
|
||||
'calculator.fac.target_output_sec': 'Ziel Output/s',
|
||||
'calculator.fac.items_per_sec': 'Items/s',
|
||||
'calculator.fac.items_per_min': 'Items/min',
|
||||
'calculator.fac.machines_per_belt': 'Maschinen/Belt',
|
||||
'calculator.fac.belt_utilization': 'Belt-Auslastung',
|
||||
'calculator.fac.machines_needed': 'Maschinen benötigt',
|
||||
'calculator.fac.belt_needed': 'Belt benötigt',
|
||||
'calculator.fac.exceeds_belt': 'Übersteigt max. Belt',
|
||||
|
||||
// Stationeers Calculator
|
||||
'calculator.tab.stationeers': 'Stationeers',
|
||||
'calculator.sta.tab.gas': 'Gas',
|
||||
'calculator.sta.tab.furnace': 'Ofen',
|
||||
'calculator.sta.tab.solar': 'Solar',
|
||||
'calculator.sta.tab.atmo': 'Atmo',
|
||||
'calculator.sta.solve_for': 'Gesucht',
|
||||
'calculator.sta.var.P': 'Druck (P)',
|
||||
'calculator.sta.var.V': 'Volumen (V)',
|
||||
'calculator.sta.var.n': 'Stoffmenge (n)',
|
||||
'calculator.sta.var.T': 'Temperatur (T)',
|
||||
'calculator.sta.var.P_label': 'Druck (kPa)',
|
||||
'calculator.sta.var.V_label': 'Volumen (L)',
|
||||
'calculator.sta.var.n_label': 'Stoffmenge (mol)',
|
||||
'calculator.sta.var.T_label': 'Temperatur (K)',
|
||||
'calculator.sta.result': 'Ergebnis',
|
||||
'calculator.sta.fuel_ratio': 'Fuel-Anteil (0-1)',
|
||||
'calculator.sta.start_temp': 'Start-Temperatur (K)',
|
||||
'calculator.sta.start_pressure': 'Start-Druck (kPa)',
|
||||
'calculator.sta.temp_after': 'T nach Zündung',
|
||||
'calculator.sta.pressure_after': 'P nach Zündung',
|
||||
'calculator.sta.warn_low_fuel': '\u26A0 Fuel unter 5%',
|
||||
'calculator.sta.warn_low_pressure': '\u26A0 Druck unter 10 kPa',
|
||||
'calculator.sta.panels': 'Anzahl Panels',
|
||||
'calculator.sta.watts_per_panel': 'Watt/Panel',
|
||||
'calculator.sta.day_length': 'Taglänge (s)',
|
||||
'calculator.sta.night_length': 'Nachtlänge (s)',
|
||||
'calculator.sta.consumption': 'Verbrauch (W)',
|
||||
'calculator.sta.generation': 'Erzeugung',
|
||||
'calculator.sta.surplus': 'Überschuss',
|
||||
'calculator.sta.night_energy': 'Nacht-Energie',
|
||||
'calculator.sta.batteries_needed': 'Batterien benötigt',
|
||||
'calculator.sta.target_temp': 'Ziel-Temperatur (K)',
|
||||
'calculator.sta.gas1_temp': 'Gas 1 Temperatur (K)',
|
||||
'calculator.sta.gas2_temp': 'Gas 2 Temperatur (K)',
|
||||
'calculator.sta.mixer_input1': 'Mixer Input 1',
|
||||
'calculator.sta.mixer_input2': 'Mixer Input 2',
|
||||
'calculator.sta.heat_cap_ref': 'Wärmekapazitäten (Referenz)',
|
||||
'calculator.sta.gas': 'Gas',
|
||||
|
||||
// Timer
|
||||
'timer.title': 'Timer',
|
||||
'timer.start': 'Start',
|
||||
'timer.pause': 'Pause',
|
||||
'timer.reset': 'Reset',
|
||||
'timer.restart': 'Neustart',
|
||||
'timer.presets': 'Presets',
|
||||
'timer.save_preset': 'Preset speichern',
|
||||
'timer.preset_name_placeholder': 'Name...',
|
||||
'timer.ok': 'OK',
|
||||
'timer.limit_title': 'Limit erreicht',
|
||||
'timer.limit_message': 'Maximale Anzahl erreicht! Du kannst maximal {max} Presets speichern.',
|
||||
'timer.no_time_title': 'Keine Zeit',
|
||||
'timer.no_time_message': 'Gib zuerst eine Zeit ein, bevor du ein Preset speicherst.',
|
||||
'timer.mute': 'Ton ausschalten',
|
||||
'timer.unmute': 'Ton einschalten',
|
||||
'timer.finished_title': '[!] Timer abgelaufen',
|
||||
'timer.default_page_title': 'Hellion Dashboard',
|
||||
|
||||
// Bild-Referenz
|
||||
'imageref.title': 'Bild-Referenz',
|
||||
'imageref.dropzone': 'Klicken oder Bild hierher ziehen',
|
||||
'imageref.replace': 'Bild ersetzen',
|
||||
'imageref.label_placeholder': 'Beschriftung (optional)',
|
||||
'imageref.storage_error': 'Bild konnte nicht gespeichert werden. Der Browser-Speicher ist voll.',
|
||||
'imageref.storage_error.title': 'Speicherfehler',
|
||||
'imageref.limit': 'Maximal {max} Bild-Widgets gleichzeitig. Schliesse eines um ein neues zu oeffnen.',
|
||||
'imageref.limit.title': 'Limit erreicht',
|
||||
'imageref.load_error': 'Bild konnte nicht geladen werden: {error}',
|
||||
'imageref.load_error.title': 'Bildfehler',
|
||||
'imageref.invalid_file': 'Bitte eine Bilddatei verwenden (PNG, JPG, WebP, etc.).',
|
||||
'imageref.invalid_file.title': 'Kein Bild',
|
||||
|
||||
// Widget-Manager
|
||||
'widget.minimize': 'Minimieren',
|
||||
'widget.close': 'Schließen',
|
||||
|
||||
// Daten (Export/Import)
|
||||
'data.invalid_format': 'Ungültiges Format',
|
||||
'data.no_boards': 'Keine gültigen Boards gefunden',
|
||||
'data.import_confirm': '{count} Boards importieren? Bestehende Daten bleiben erhalten.',
|
||||
'data.import_confirm.title': 'JSON Import',
|
||||
'data.import_success': '{boards} Board(s){notes}{calc}{timer} erfolgreich importiert.',
|
||||
'data.import_success.title': 'Import erfolgreich',
|
||||
'data.import_error': 'Fehler beim Import: {error}',
|
||||
'data.import_error.title': 'Import fehlgeschlagen',
|
||||
'data.notes_suffix': ' + {count} Note(s)',
|
||||
'data.calc_suffix': ' + Calculator-History',
|
||||
'data.timer_suffix': ' + Timer-Presets',
|
||||
|
||||
// Browser-Lesezeichen Import
|
||||
'bm_import.no_access': 'Zugriff auf Browser-Lesezeichen nicht möglich. Stelle sicher, dass die Extension die nötigen Berechtigungen hat.',
|
||||
'bm_import.title': 'Lesezeichen-Import',
|
||||
'bm_import.no_folders': 'Keine Lesezeichen-Ordner gefunden.',
|
||||
'bm_import.modal_title': 'Browser-Lesezeichen importieren',
|
||||
'bm_import.info': 'Wähle die Ordner aus, die als Boards importiert werden sollen. Jeder Ordner wird ein eigenes Board.',
|
||||
'bm_import.unnamed': 'Unbenannt',
|
||||
'bm_import.link_count': '{count} Link(s)',
|
||||
'bm_import.folder_count': '{count} Ordner',
|
||||
'bm_import.empty': 'leer',
|
||||
'bm_import.select_all': 'Alle auswählen',
|
||||
'bm_import.deselect_all': 'Alle abwählen',
|
||||
'bm_import.import_btn': 'Importieren',
|
||||
'bm_import.no_selection': 'Bitte wähle mindestens einen Ordner aus.',
|
||||
'bm_import.boards_created': '{count} Board(s) erstellt',
|
||||
'bm_import.bookmarks_imported': '{count} Lesezeichen importiert',
|
||||
'bm_import.duplicates_skipped': '{count} Duplikat(e) übersprungen',
|
||||
'bm_import.success_title': 'Import abgeschlossen',
|
||||
|
||||
// Storage
|
||||
'storage.quota_full': 'Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.',
|
||||
'storage.quota_full.title': 'Speicher voll',
|
||||
|
||||
// App
|
||||
'app.backup_reminder': 'Du hast seit über einer Woche kein Backup gemacht. Beim Löschen der Browserdaten gehen deine Boards verloren. Jetzt sichern?',
|
||||
'app.backup_reminder.title': 'Backup-Erinnerung',
|
||||
'app.backup_now': 'Jetzt sichern',
|
||||
'app.backup_later': 'Später',
|
||||
'app.no_bookmarks': 'Keine Bookmarks in dieser Datei gefunden.',
|
||||
'app.import_title': 'Import',
|
||||
'app.html_import_success': '{count} Board(s) mit {total} Bookmarks importiert.',
|
||||
'app.import_success_title': 'Import erfolgreich',
|
||||
'app.invalid_url': 'Ungültige URL. Bitte mit https:// beginnen.',
|
||||
'app.invalid_url.title': 'URL ungültig',
|
||||
|
||||
// Uhr
|
||||
'clock.days.sun': 'So',
|
||||
'clock.days.mon': 'Mo',
|
||||
'clock.days.tue': 'Di',
|
||||
'clock.days.wed': 'Mi',
|
||||
'clock.days.thu': 'Do',
|
||||
'clock.days.fri': 'Fr',
|
||||
'clock.days.sat': 'Sa',
|
||||
'clock.months.jan': 'Jan',
|
||||
'clock.months.feb': 'Feb',
|
||||
'clock.months.mar': 'Mär',
|
||||
'clock.months.apr': 'Apr',
|
||||
'clock.months.may': 'Mai',
|
||||
'clock.months.jun': 'Jun',
|
||||
'clock.months.jul': 'Jul',
|
||||
'clock.months.aug': 'Aug',
|
||||
'clock.months.sep': 'Sep',
|
||||
'clock.months.oct': 'Okt',
|
||||
'clock.months.nov': 'Nov',
|
||||
'clock.months.dec': 'Dez',
|
||||
|
||||
// Settings
|
||||
'settings.file_read_error': 'Fehler beim Lesen der Datei. Bitte eine andere Datei wählen.',
|
||||
'settings.file_read_error.title': 'Dateifehler',
|
||||
'settings.reset_confirm': 'Wirklich alle Boards und Einstellungen löschen? Das kann nicht rückgängig gemacht werden.',
|
||||
'settings.reset_confirm.title': 'Alles zurücksetzen',
|
||||
'settings.reset_confirm.button': 'Alles löschen',
|
||||
|
||||
// Header
|
||||
'header.import': 'Import',
|
||||
'header.board': 'Board',
|
||||
'header.note': 'Note',
|
||||
'header.theme': 'Darstellung',
|
||||
'header.settings': 'Einstellungen',
|
||||
|
||||
// Header Tooltips
|
||||
'header.import_title': 'Bookmarks importieren (HTML)',
|
||||
'header.board_title': 'Neues Board hinzufügen',
|
||||
'header.note_title': 'Schnellnotiz',
|
||||
'header.theme_title': 'Darstellung & Theme',
|
||||
'header.settings_title': 'Einstellungen',
|
||||
|
||||
// Settings-Panel Überschrift
|
||||
'settings.title': 'Einstellungen',
|
||||
|
||||
// Settings-Panel Sektionen
|
||||
'settings.section.widgets': 'WIDGETS',
|
||||
'settings.section.data': 'DATEN & HILFE',
|
||||
'settings.section.danger': 'DANGER ZONE',
|
||||
'settings.section.bg': 'HINTERGRUND',
|
||||
'settings.section.display': 'DARSTELLUNG',
|
||||
|
||||
// Settings-Zeilen
|
||||
'settings.language': 'Sprache',
|
||||
'settings.language.desc': 'Anzeigesprache wählen',
|
||||
'settings.language.auto': 'Automatisch',
|
||||
'settings.toolbar_pos': 'Toolbar-Position',
|
||||
'settings.toolbar_pos.desc': 'Widget-Toolbar links oder rechts anzeigen',
|
||||
'settings.toolbar_pos.right': 'Rechts',
|
||||
'settings.toolbar_pos.left': 'Links',
|
||||
'settings.image_ref': 'Bild-Referenz Widgets',
|
||||
'settings.image_ref.desc': 'Bilder als Referenz anzeigen (nur aktuelle Session)',
|
||||
'settings.export': 'Backup exportieren',
|
||||
'settings.export.desc': 'Alle Boards, Notes und Einstellungen als JSON sichern',
|
||||
'settings.export.btn': 'Export',
|
||||
'settings.import': 'Backup importieren',
|
||||
'settings.import.desc': 'JSON-Backup wiederherstellen',
|
||||
'settings.browser_import': 'Browser-Lesezeichen',
|
||||
'settings.browser_import.desc': 'Lesezeichen direkt aus dem Browser importieren',
|
||||
'settings.onboarding': 'Onboarding wiederholen',
|
||||
'settings.onboarding.desc': 'Willkommens-Tour erneut anzeigen',
|
||||
'settings.reset': 'Alles zurücksetzen',
|
||||
'settings.reset.desc': 'Löscht alle Boards, Notes und Einstellungen',
|
||||
'settings.compact': 'Kompaktmodus',
|
||||
'settings.compact.desc': 'Weniger Abstand für mehr Bookmarks',
|
||||
'settings.shorten': 'Lange Titel kürzen',
|
||||
'settings.shorten.desc': 'Titel auf eine Zeile mit „…" kürzen',
|
||||
'settings.search': 'Suchleiste anzeigen',
|
||||
'settings.search.desc': 'Suchleiste unter dem Header ein/aus',
|
||||
'settings.newtab': 'Links in neuem Tab',
|
||||
'settings.newtab.desc': 'Bookmarks in neuem Browser-Tab öffnen',
|
||||
'settings.showdesc': 'Beschreibungen anzeigen',
|
||||
'settings.showdesc.desc': 'Gespeicherte Beschreibung unter Bookmarks',
|
||||
'settings.hideextra': 'Bookmarks ausblenden',
|
||||
'settings.hideextra.desc': 'Überzählige Bookmarks in langen Boards verstecken',
|
||||
'settings.visible_count': 'Sichtbare Bookmarks',
|
||||
'settings.visible_count.desc': 'Anzahl vor dem Ausblenden',
|
||||
'settings.bg_url': 'Bild-URL',
|
||||
'settings.bg_url.desc': 'Eigenes Hintergrundbild per URL',
|
||||
'settings.bg_change': 'Ändern',
|
||||
'settings.bg_apply': 'Übernehmen',
|
||||
'settings.bg_upload': 'Datei hochladen',
|
||||
'settings.bg_upload.desc': 'Lokales Bild als Hintergrund verwenden',
|
||||
'settings.search_engine_toggle': 'Suchmaschine wechseln',
|
||||
|
||||
// Settings Buttons + Validierung
|
||||
'settings.onboarding_btn': 'Start',
|
||||
'settings.reset_btn': 'Reset',
|
||||
'settings.bg_upload_btn': 'Upload',
|
||||
'settings.bg_invalid_url': 'Nur lokale Bilder (Upload) sind als Hintergrund erlaubt.',
|
||||
'settings.bg_invalid_url.title': 'Ungültige URL',
|
||||
|
||||
// Modals
|
||||
'modal.new_board': 'Neues Board',
|
||||
'modal.board_name': 'Board-Name...',
|
||||
'modal.create': 'Erstellen',
|
||||
'modal.new_bookmark': 'Neues Lesezeichen',
|
||||
'modal.bm_title': 'Titel...',
|
||||
'modal.bm_desc': 'Beschreibung (optional)',
|
||||
'modal.bm_add': 'Hinzufügen',
|
||||
'modal.rename': 'Umbenennen',
|
||||
'modal.rename_placeholder': 'Neuer Name...',
|
||||
'modal.rename_confirm': 'Umbenennen',
|
||||
'modal.theme_header': 'Darstellung',
|
||||
|
||||
// About
|
||||
'about.title': '⬡ HELLION NEWTAB',
|
||||
'about.impressum': 'Impressum',
|
||||
'about.developer': 'Entwickler',
|
||||
'about.company': 'Unternehmen',
|
||||
'about.license': 'Lizenz',
|
||||
'about.storage': 'Datenspeicherung',
|
||||
'about.storage.value': '100% lokal · Kein Server · Kein Account',
|
||||
'about.bugreport': 'Bug Report / Feedback',
|
||||
'about.support': 'Support',
|
||||
'about.browsers': 'Kompatible Browser',
|
||||
|
||||
// Notebook
|
||||
'notebook.title': 'Notebook',
|
||||
|
||||
// Suche
|
||||
'search.placeholder': 'Im Web suchen…',
|
||||
'search.submit_title': 'Suchen',
|
||||
|
||||
// Widget-Toolbar Tooltips
|
||||
'toolbar.note': 'Note erstellen',
|
||||
'toolbar.checklist': 'Checkliste erstellen',
|
||||
'toolbar.calculator': 'Taschenrechner',
|
||||
'toolbar.timer': 'Timer',
|
||||
'toolbar.imageref': 'Bild-Referenz',
|
||||
'toolbar.notebook': 'Alle Notes'
|
||||
},
|
||||
|
||||
en: {
|
||||
// Dialog system
|
||||
'dialog.default_title': 'Notice',
|
||||
'dialog.ok': 'OK',
|
||||
'dialog.confirm_title': 'Confirmation',
|
||||
'dialog.cancel': 'Cancel',
|
||||
'dialog.close': 'Close',
|
||||
|
||||
// Boards
|
||||
'boards.empty_state_pre': 'No boards yet. Click ',
|
||||
'boards.add_board': '+ Board',
|
||||
'boards.empty_state_mid': ' to create one, or use ',
|
||||
'boards.import': 'Import',
|
||||
'boards.empty_state_post': ' to load your browser bookmarks.',
|
||||
'boards.drag_title': 'Move board',
|
||||
'boards.blur': 'Blur (private)',
|
||||
'boards.unblur': 'Unblur',
|
||||
'boards.rename': 'Rename',
|
||||
'boards.delete': 'Delete',
|
||||
'boards.delete_confirm': 'Really delete board "{title}"?',
|
||||
'boards.delete_confirm.title': 'Delete board',
|
||||
'boards.show_more': 'Show {count} more…',
|
||||
'boards.show_less': 'Show less',
|
||||
'boards.add_link': ' Add link',
|
||||
'boards.remove_bookmark': 'Remove',
|
||||
|
||||
// Onboarding
|
||||
'onboarding.skip': 'Skip',
|
||||
'onboarding.back': 'Back',
|
||||
'onboarding.next': 'Next',
|
||||
'onboarding.start': 'Let\'s go!',
|
||||
'onboarding.yes': 'Yes please',
|
||||
'onboarding.no': 'No thanks',
|
||||
'onboarding.s1.title': 'Welcome to Hellion Dashboard',
|
||||
'onboarding.s1.text': 'Your new browser start screen. Minimalist, fast and fully local — no cloud, no account, no data collection.',
|
||||
'onboarding.s2.title': 'Boards & Bookmarks',
|
||||
'onboarding.s2.f1': 'Create boards with the "+ Board" button at the top',
|
||||
'onboarding.s2.f2': 'Import browser bookmarks via the "Import" button in the header',
|
||||
'onboarding.s2.f3': 'Drag & drop to reorder boards and links',
|
||||
'onboarding.s2.f4': 'Blur mode for private boards (🔒 icon)',
|
||||
'onboarding.s3.title': '11 handcrafted themes',
|
||||
'onboarding.s3.text': 'Click the "Theme" button in the header to choose your theme. Each has its own style and color palette.',
|
||||
'onboarding.s4.title': 'Widget Toolbar',
|
||||
'onboarding.s4.f1': 'The floating buttons on the right open widgets',
|
||||
'onboarding.s4.f2': 'Notes and checklists for quick notes',
|
||||
'onboarding.s4.f3': 'Calculator with history',
|
||||
'onboarding.s4.f4': 'Timer/countdown with saveable presets',
|
||||
'onboarding.s4.f5': 'Image reference widgets (enable in Settings)',
|
||||
'onboarding.s4.f6': 'Notebook sidebar shows all notes at a glance',
|
||||
'onboarding.s5.title': 'Don\'t forget backups!',
|
||||
'onboarding.s5.text': 'Your data is stored locally in the browser. If you clear browser data, it\'s gone! Back up regularly via Settings → Data → Export. We\'ll remind you every 7 days.',
|
||||
'onboarding.s6.title': 'Gaming Starter Board',
|
||||
'onboarding.s6.text': 'Do you play games like Satisfactory, Factorio or Star Citizen? I can create a board with useful community links.',
|
||||
'onboarding.tradecenter_desc': 'Trade Center for Star Citizen',
|
||||
'onboarding.s7.title': 'Ready!',
|
||||
'onboarding.s7.text': 'Create your first board with "+ Board" or import your browser bookmarks via the Import button in the header. Have fun!',
|
||||
|
||||
// Notes
|
||||
'notes.limit_message': 'Maximum reached! You can have at most {max} notes at the same time. Delete an existing note to create a new one.',
|
||||
'notes.limit_title': 'Limit reached',
|
||||
'notes.checklist_title': 'Checklist',
|
||||
'notes.default_title': 'Note',
|
||||
'notes.placeholder': 'Write a note...',
|
||||
'notes.checklist_placeholder': 'New item...',
|
||||
'notes.delete_confirm': 'Permanently delete note? This cannot be undone.',
|
||||
'notes.delete_title': 'Delete note',
|
||||
'notes.delete_button': 'Delete',
|
||||
'notes.checklist_progress': '{done}/{total} done',
|
||||
'notes.empty_preview': 'Empty',
|
||||
'notes.export': 'Export',
|
||||
'notes.export_footer': 'Exported from Hellion Dashboard',
|
||||
'notes.create': '+ Create note',
|
||||
'notes.text_type': '✎ Free text',
|
||||
'notes.checklist_type': '☑ Checklist',
|
||||
|
||||
// Calculator
|
||||
'calculator.title': 'Calculator',
|
||||
'calculator.history': 'History',
|
||||
'calculator.error': 'Error',
|
||||
'calculator.tab.standard': 'Standard',
|
||||
'calculator.tab.scientific': 'Scientific',
|
||||
'calculator.sci.formulas': 'Formula Helper',
|
||||
'calculator.sci.select_formula': 'Choose formula…',
|
||||
'calculator.sci.formula.circle_area': 'Circle Area (π×r²)',
|
||||
'calculator.sci.formula.circle_circumference':'Circle Circumference (2πr)',
|
||||
'calculator.sci.formula.celsius_to_fahrenheit':'°C → °F',
|
||||
'calculator.sci.formula.fahrenheit_to_celsius':'°F → °C',
|
||||
'calculator.sci.formula.pythagoras': 'Pythagoras (√(a²+b²))',
|
||||
'calculator.sci.formula.percentage': 'Percentage',
|
||||
'calculator.sci.field.radius': 'Radius',
|
||||
'calculator.sci.field.temp': 'Temperature',
|
||||
'calculator.sci.field.a': 'Side a',
|
||||
'calculator.sci.field.b': 'Side b',
|
||||
'calculator.sci.field.value': 'Value',
|
||||
'calculator.sci.field.percent': 'Percent',
|
||||
'calculator.tab.converter': 'Converter',
|
||||
'calculator.conv.swap': 'Swap units',
|
||||
'calculator.conv.cat.length': 'Length',
|
||||
'calculator.conv.cat.weight': 'Weight',
|
||||
'calculator.conv.cat.temperature': 'Temperature',
|
||||
'calculator.conv.cat.volume': 'Volume',
|
||||
'calculator.conv.cat.speed': 'Speed',
|
||||
'calculator.conv.cat.area': 'Area',
|
||||
'calculator.tab.satisfactory': 'Satisfactory',
|
||||
'calculator.sat.tab.itemsPerMin': 'Items/Min',
|
||||
'calculator.sat.tab.power': 'Power',
|
||||
'calculator.sat.tab.machines': 'Machines',
|
||||
'calculator.sat.items_per_craft': 'Items/Craft',
|
||||
'calculator.sat.craft_time': 'Craft Time (s)',
|
||||
'calculator.sat.clock_speed': 'Clock Speed (%)',
|
||||
'calculator.sat.base_power': 'Base Power (MW)',
|
||||
'calculator.sat.target_output': 'Target Output/Min',
|
||||
'calculator.sat.output_per_min': 'Output',
|
||||
'calculator.sat.power_usage': 'Power Usage',
|
||||
'calculator.sat.efficiency': 'Efficiency',
|
||||
'calculator.sat.per_item': 'per item',
|
||||
'calculator.sat.machines_needed': 'Machines needed',
|
||||
'calculator.sat.total_power': 'Total Power',
|
||||
|
||||
// Factorio Calculator
|
||||
'calculator.tab.factorio': 'Factorio',
|
||||
'calculator.fac.tab.ratio': 'Ratio',
|
||||
'calculator.fac.tab.belt': 'Belt',
|
||||
'calculator.fac.tab.machines': 'Machines',
|
||||
'calculator.fac.assembler': 'Assembler',
|
||||
'calculator.fac.asm.asm1': 'Assembler 1',
|
||||
'calculator.fac.asm.asm2': 'Assembler 2',
|
||||
'calculator.fac.asm.asm3': 'Assembler 3',
|
||||
'calculator.fac.belt': 'Belt Type',
|
||||
'calculator.fac.belt.yellow': 'Yellow',
|
||||
'calculator.fac.belt.red': 'Red',
|
||||
'calculator.fac.belt.blue': 'Blue',
|
||||
'calculator.fac.recipe_output': 'Recipe Output',
|
||||
'calculator.fac.recipe_time': 'Recipe Time (s)',
|
||||
'calculator.fac.consume_per_sec': 'Consume/s',
|
||||
'calculator.fac.target_output_sec': 'Target Output/s',
|
||||
'calculator.fac.items_per_sec': 'Items/s',
|
||||
'calculator.fac.items_per_min': 'Items/min',
|
||||
'calculator.fac.machines_per_belt': 'Machines/Belt',
|
||||
'calculator.fac.belt_utilization': 'Belt Utilization',
|
||||
'calculator.fac.machines_needed': 'Machines needed',
|
||||
'calculator.fac.belt_needed': 'Belt needed',
|
||||
'calculator.fac.exceeds_belt': 'Exceeds max belt',
|
||||
|
||||
// Stationeers Calculator
|
||||
'calculator.tab.stationeers': 'Stationeers',
|
||||
'calculator.sta.tab.gas': 'Gas',
|
||||
'calculator.sta.tab.furnace': 'Furnace',
|
||||
'calculator.sta.tab.solar': 'Solar',
|
||||
'calculator.sta.tab.atmo': 'Atmo',
|
||||
'calculator.sta.solve_for': 'Solve for',
|
||||
'calculator.sta.var.P': 'Pressure (P)',
|
||||
'calculator.sta.var.V': 'Volume (V)',
|
||||
'calculator.sta.var.n': 'Amount (n)',
|
||||
'calculator.sta.var.T': 'Temperature (T)',
|
||||
'calculator.sta.var.P_label': 'Pressure (kPa)',
|
||||
'calculator.sta.var.V_label': 'Volume (L)',
|
||||
'calculator.sta.var.n_label': 'Amount (mol)',
|
||||
'calculator.sta.var.T_label': 'Temperature (K)',
|
||||
'calculator.sta.result': 'Result',
|
||||
'calculator.sta.fuel_ratio': 'Fuel Ratio (0-1)',
|
||||
'calculator.sta.start_temp': 'Start Temperature (K)',
|
||||
'calculator.sta.start_pressure': 'Start Pressure (kPa)',
|
||||
'calculator.sta.temp_after': 'T after ignition',
|
||||
'calculator.sta.pressure_after': 'P after ignition',
|
||||
'calculator.sta.warn_low_fuel': '\u26A0 Fuel below 5%',
|
||||
'calculator.sta.warn_low_pressure': '\u26A0 Pressure below 10 kPa',
|
||||
'calculator.sta.panels': 'Panel Count',
|
||||
'calculator.sta.watts_per_panel': 'Watts/Panel',
|
||||
'calculator.sta.day_length': 'Day Length (s)',
|
||||
'calculator.sta.night_length': 'Night Length (s)',
|
||||
'calculator.sta.consumption': 'Consumption (W)',
|
||||
'calculator.sta.generation': 'Generation',
|
||||
'calculator.sta.surplus': 'Surplus',
|
||||
'calculator.sta.night_energy': 'Night Energy',
|
||||
'calculator.sta.batteries_needed': 'Batteries needed',
|
||||
'calculator.sta.target_temp': 'Target Temperature (K)',
|
||||
'calculator.sta.gas1_temp': 'Gas 1 Temperature (K)',
|
||||
'calculator.sta.gas2_temp': 'Gas 2 Temperature (K)',
|
||||
'calculator.sta.mixer_input1': 'Mixer Input 1',
|
||||
'calculator.sta.mixer_input2': 'Mixer Input 2',
|
||||
'calculator.sta.heat_cap_ref': 'Heat Capacities (Reference)',
|
||||
'calculator.sta.gas': 'Gas',
|
||||
|
||||
// Timer
|
||||
'timer.title': 'Timer',
|
||||
'timer.start': 'Start',
|
||||
'timer.pause': 'Pause',
|
||||
'timer.reset': 'Reset',
|
||||
'timer.restart': 'Restart',
|
||||
'timer.presets': 'Presets',
|
||||
'timer.save_preset': 'Save preset',
|
||||
'timer.preset_name_placeholder': 'Name...',
|
||||
'timer.ok': 'OK',
|
||||
'timer.limit_title': 'Limit reached',
|
||||
'timer.limit_message': 'Maximum reached! You can save at most {max} presets.',
|
||||
'timer.no_time_title': 'No time',
|
||||
'timer.no_time_message': 'Enter a time before saving a preset.',
|
||||
'timer.mute': 'Mute sound',
|
||||
'timer.unmute': 'Unmute sound',
|
||||
'timer.finished_title': '[!] Timer finished',
|
||||
'timer.default_page_title': 'Hellion Dashboard',
|
||||
|
||||
// Image reference
|
||||
'imageref.title': 'Image Reference',
|
||||
'imageref.dropzone': 'Click or drag image here',
|
||||
'imageref.replace': 'Replace image',
|
||||
'imageref.label_placeholder': 'Caption (optional)',
|
||||
'imageref.storage_error': 'Image could not be saved. Browser storage is full.',
|
||||
'imageref.storage_error.title': 'Storage error',
|
||||
'imageref.limit': 'Maximum {max} image widgets at a time. Close one to open a new one.',
|
||||
'imageref.limit.title': 'Limit reached',
|
||||
'imageref.load_error': 'Image could not be loaded: {error}',
|
||||
'imageref.load_error.title': 'Image error',
|
||||
'imageref.invalid_file': 'Please use an image file (PNG, JPG, WebP, etc.).',
|
||||
'imageref.invalid_file.title': 'Not an image',
|
||||
|
||||
// Widget manager
|
||||
'widget.minimize': 'Minimize',
|
||||
'widget.close': 'Close',
|
||||
|
||||
// Data (export/import)
|
||||
'data.invalid_format': 'Invalid format',
|
||||
'data.no_boards': 'No valid boards found',
|
||||
'data.import_confirm': 'Import {count} boards? Existing data will be preserved.',
|
||||
'data.import_confirm.title': 'JSON Import',
|
||||
'data.import_success': '{boards} board(s){notes}{calc}{timer} successfully imported.',
|
||||
'data.import_success.title': 'Import successful',
|
||||
'data.import_error': 'Import error: {error}',
|
||||
'data.import_error.title': 'Import failed',
|
||||
'data.notes_suffix': ' + {count} note(s)',
|
||||
'data.calc_suffix': ' + Calculator history',
|
||||
'data.timer_suffix': ' + Timer presets',
|
||||
|
||||
// Browser bookmark import
|
||||
'bm_import.no_access': 'Cannot access browser bookmarks. Make sure the extension has the required permissions.',
|
||||
'bm_import.title': 'Bookmark import',
|
||||
'bm_import.no_folders': 'No bookmark folders found.',
|
||||
'bm_import.modal_title': 'Import browser bookmarks',
|
||||
'bm_import.info': 'Select the folders to import as boards. Each folder becomes its own board.',
|
||||
'bm_import.unnamed': 'Unnamed',
|
||||
'bm_import.link_count': '{count} link(s)',
|
||||
'bm_import.folder_count': '{count} folder(s)',
|
||||
'bm_import.empty': 'empty',
|
||||
'bm_import.select_all': 'Select all',
|
||||
'bm_import.deselect_all': 'Deselect all',
|
||||
'bm_import.import_btn': 'Import',
|
||||
'bm_import.no_selection': 'Please select at least one folder.',
|
||||
'bm_import.boards_created': '{count} board(s) created',
|
||||
'bm_import.bookmarks_imported': '{count} bookmarks imported',
|
||||
'bm_import.duplicates_skipped': '{count} duplicate(s) skipped',
|
||||
'bm_import.success_title': 'Import complete',
|
||||
|
||||
// Storage
|
||||
'storage.quota_full': 'Storage full! Please delete old boards or the background image to free up space.',
|
||||
'storage.quota_full.title': 'Storage full',
|
||||
|
||||
// App
|
||||
'app.backup_reminder': 'You haven\'t made a backup in over a week. If you clear browser data, your boards will be lost. Back up now?',
|
||||
'app.backup_reminder.title': 'Backup reminder',
|
||||
'app.backup_now': 'Back up now',
|
||||
'app.backup_later': 'Later',
|
||||
'app.no_bookmarks': 'No bookmarks found in this file.',
|
||||
'app.import_title': 'Import',
|
||||
'app.html_import_success': '{count} board(s) with {total} bookmarks imported.',
|
||||
'app.import_success_title': 'Import successful',
|
||||
'app.invalid_url': 'Invalid URL. Please start with https://.',
|
||||
'app.invalid_url.title': 'Invalid URL',
|
||||
|
||||
// Clock
|
||||
'clock.days.sun': 'Sun',
|
||||
'clock.days.mon': 'Mon',
|
||||
'clock.days.tue': 'Tue',
|
||||
'clock.days.wed': 'Wed',
|
||||
'clock.days.thu': 'Thu',
|
||||
'clock.days.fri': 'Fri',
|
||||
'clock.days.sat': 'Sat',
|
||||
'clock.months.jan': 'Jan',
|
||||
'clock.months.feb': 'Feb',
|
||||
'clock.months.mar': 'Mar',
|
||||
'clock.months.apr': 'Apr',
|
||||
'clock.months.may': 'May',
|
||||
'clock.months.jun': 'Jun',
|
||||
'clock.months.jul': 'Jul',
|
||||
'clock.months.aug': 'Aug',
|
||||
'clock.months.sep': 'Sep',
|
||||
'clock.months.oct': 'Oct',
|
||||
'clock.months.nov': 'Nov',
|
||||
'clock.months.dec': 'Dec',
|
||||
|
||||
// Settings
|
||||
'settings.file_read_error': 'Error reading file. Please choose a different file.',
|
||||
'settings.file_read_error.title': 'File error',
|
||||
'settings.reset_confirm': 'Really delete all boards and settings? This cannot be undone.',
|
||||
'settings.reset_confirm.title': 'Reset everything',
|
||||
'settings.reset_confirm.button': 'Delete all',
|
||||
|
||||
// Header
|
||||
'header.import': 'Import',
|
||||
'header.board': 'Board',
|
||||
'header.note': 'Note',
|
||||
'header.theme': 'Theme',
|
||||
'header.settings': 'Settings',
|
||||
|
||||
// Header Tooltips
|
||||
'header.import_title': 'Import bookmarks (HTML)',
|
||||
'header.board_title': 'Add new board',
|
||||
'header.note_title': 'Quick note',
|
||||
'header.theme_title': 'Appearance & Theme',
|
||||
'header.settings_title': 'Settings',
|
||||
|
||||
// Settings panel heading
|
||||
'settings.title': 'Settings',
|
||||
|
||||
// Settings panel sections
|
||||
'settings.section.widgets': 'WIDGETS',
|
||||
'settings.section.data': 'DATA & HELP',
|
||||
'settings.section.danger': 'DANGER ZONE',
|
||||
'settings.section.bg': 'BACKGROUND',
|
||||
'settings.section.display': 'DISPLAY',
|
||||
|
||||
// Settings rows
|
||||
'settings.language': 'Language',
|
||||
'settings.language.desc': 'Choose display language',
|
||||
'settings.language.auto': 'Automatic',
|
||||
'settings.toolbar_pos': 'Toolbar position',
|
||||
'settings.toolbar_pos.desc': 'Show widget toolbar on the left or right',
|
||||
'settings.toolbar_pos.right': 'Right',
|
||||
'settings.toolbar_pos.left': 'Left',
|
||||
'settings.image_ref': 'Image reference widgets',
|
||||
'settings.image_ref.desc': 'Show images as reference (current session only)',
|
||||
'settings.export': 'Export backup',
|
||||
'settings.export.desc': 'Save all boards, notes and settings as JSON',
|
||||
'settings.export.btn': 'Export',
|
||||
'settings.import': 'Import backup',
|
||||
'settings.import.desc': 'Restore a JSON backup',
|
||||
'settings.browser_import': 'Browser bookmarks',
|
||||
'settings.browser_import.desc': 'Import bookmarks directly from the browser',
|
||||
'settings.onboarding': 'Replay onboarding',
|
||||
'settings.onboarding.desc': 'Show the welcome tour again',
|
||||
'settings.reset': 'Reset everything',
|
||||
'settings.reset.desc': 'Deletes all boards, notes and settings',
|
||||
'settings.compact': 'Compact mode',
|
||||
'settings.compact.desc': 'Less spacing for more bookmarks',
|
||||
'settings.shorten': 'Shorten long titles',
|
||||
'settings.shorten.desc': 'Truncate titles to one line with "…"',
|
||||
'settings.search': 'Show search bar',
|
||||
'settings.search.desc': 'Toggle search bar below the header',
|
||||
'settings.newtab': 'Links in new tab',
|
||||
'settings.newtab.desc': 'Open bookmarks in a new browser tab',
|
||||
'settings.showdesc': 'Show descriptions',
|
||||
'settings.showdesc.desc': 'Show saved description below bookmarks',
|
||||
'settings.hideextra': 'Hide bookmarks',
|
||||
'settings.hideextra.desc': 'Hide excess bookmarks in long boards',
|
||||
'settings.visible_count': 'Visible bookmarks',
|
||||
'settings.visible_count.desc': 'Number before hiding',
|
||||
'settings.bg_url': 'Image URL',
|
||||
'settings.bg_url.desc': 'Custom background image via URL',
|
||||
'settings.bg_change': 'Change',
|
||||
'settings.bg_apply': 'Apply',
|
||||
'settings.bg_upload': 'Upload file',
|
||||
'settings.bg_upload.desc': 'Use a local image as background',
|
||||
'settings.search_engine_toggle': 'Switch search engine',
|
||||
|
||||
// Settings Buttons + Validation
|
||||
'settings.onboarding_btn': 'Start',
|
||||
'settings.reset_btn': 'Reset',
|
||||
'settings.bg_upload_btn': 'Upload',
|
||||
'settings.bg_invalid_url': 'Only local images (upload) are allowed as background.',
|
||||
'settings.bg_invalid_url.title': 'Invalid URL',
|
||||
|
||||
// Modals
|
||||
'modal.new_board': 'New Board',
|
||||
'modal.board_name': 'Board name...',
|
||||
'modal.create': 'Create',
|
||||
'modal.new_bookmark': 'New Bookmark',
|
||||
'modal.bm_title': 'Title...',
|
||||
'modal.bm_desc': 'Description (optional)',
|
||||
'modal.bm_add': 'Add',
|
||||
'modal.rename': 'Rename',
|
||||
'modal.rename_placeholder': 'New name...',
|
||||
'modal.rename_confirm': 'Rename',
|
||||
'modal.theme_header': 'Theme',
|
||||
|
||||
// About
|
||||
'about.title': '⬡ HELLION NEWTAB',
|
||||
'about.impressum': 'Legal Notice',
|
||||
'about.developer': 'Developer',
|
||||
'about.company': 'Company',
|
||||
'about.license': 'License',
|
||||
'about.storage': 'Data storage',
|
||||
'about.storage.value': '100% local · No server · No account',
|
||||
'about.bugreport': 'Bug Report / Feedback',
|
||||
'about.support': 'Support',
|
||||
'about.browsers': 'Compatible browsers',
|
||||
|
||||
// Notebook
|
||||
'notebook.title': 'Notebook',
|
||||
|
||||
// Search
|
||||
'search.placeholder': 'Search the web…',
|
||||
'search.submit_title': 'Search',
|
||||
|
||||
// Widget toolbar tooltips
|
||||
'toolbar.note': 'Create note',
|
||||
'toolbar.checklist': 'Create checklist',
|
||||
'toolbar.calculator': 'Calculator',
|
||||
'toolbar.timer': 'Timer',
|
||||
'toolbar.imageref': 'Image reference',
|
||||
'toolbar.notebook': 'All notes'
|
||||
}
|
||||
};
|
||||
|
||||
/** @type {string} Aktuell aktive Sprache */
|
||||
let currentLang = 'de';
|
||||
|
||||
/**
|
||||
* Übersetzungsstring abrufen mit optionalen Platzhaltern
|
||||
* @param {string} key - Schlüssel im STRINGS-Objekt
|
||||
* @param {Object} [vars] - Platzhalter-Werte (z.B. { max: 5 })
|
||||
* @returns {string}
|
||||
*/
|
||||
function t(key, vars) {
|
||||
let str = (STRINGS[currentLang] && STRINGS[currentLang][key])
|
||||
|| (STRINGS['en'] && STRINGS['en'][key])
|
||||
|| key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
str = str.replaceAll('{' + k + '}', v);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle data-i18n Elemente im Dokument mit aktueller Sprache befüllen
|
||||
*/
|
||||
function applyLanguage() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
el.textContent = t(el.dataset.i18n);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
el.placeholder = t(el.dataset.i18nPlaceholder);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const text = t(el.dataset.i18nTitle);
|
||||
el.title = text;
|
||||
el.setAttribute('aria-label', text);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 'auto' auflösen zu konkretem Sprachcode
|
||||
* @param {string} lang - 'de', 'en' oder 'auto'
|
||||
* @returns {string} 'de' oder 'en'
|
||||
*/
|
||||
function resolveLang(lang) {
|
||||
return (lang === 'auto')
|
||||
? (navigator.language.startsWith('de') ? 'de' : 'en')
|
||||
: lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprache setzen, speichern und DOM aktualisieren
|
||||
* @param {string} lang - 'de', 'en' oder 'auto'
|
||||
*/
|
||||
function setLanguage(lang) {
|
||||
currentLang = resolveLang(lang);
|
||||
document.documentElement.lang = currentLang;
|
||||
applyLanguage();
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n-Modul — öffentliche API
|
||||
*/
|
||||
const I18n = {
|
||||
/** Aktuell aktive Sprache (nach Auto-Auflösung) */
|
||||
get currentLang() { return currentLang; },
|
||||
|
||||
/**
|
||||
* Initialisierung: Sprache aus Settings lesen, auflösen, DOM anwenden
|
||||
* Muss NACH dem Laden des settings-Objekts aufgerufen werden
|
||||
*/
|
||||
init() {
|
||||
const lang = (typeof settings !== 'undefined' && settings.language)
|
||||
? settings.language
|
||||
: 'auto';
|
||||
currentLang = resolveLang(lang);
|
||||
document.documentElement.lang = currentLang;
|
||||
applyLanguage();
|
||||
}
|
||||
};
|
||||
@@ -114,8 +114,8 @@ const ImageRef = {
|
||||
} catch (e) {
|
||||
console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e);
|
||||
HellionDialog.alert(
|
||||
t('imageref.storage_error'),
|
||||
{ type: 'danger', title: t('imageref.storage_error.title') }
|
||||
'Bild konnte nicht gespeichert werden. Der Browser-Speicher ist voll.',
|
||||
{ type: 'danger', title: 'Speicherfehler' }
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -144,8 +144,8 @@ const ImageRef = {
|
||||
|
||||
if (this._images.length >= this.MAX_IMAGES) {
|
||||
await HellionDialog.alert(
|
||||
t('imageref.limit', { max: this.MAX_IMAGES }),
|
||||
{ type: 'warning', title: t('imageref.limit.title') }
|
||||
'Maximal ' + this.MAX_IMAGES + ' Bild-Widgets gleichzeitig. Schliesse eines um ein neues zu oeffnen.',
|
||||
{ type: 'warning', title: 'Limit erreicht' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -172,8 +172,8 @@ const ImageRef = {
|
||||
dataUrl = await this._processFile(file);
|
||||
} catch (err) {
|
||||
await HellionDialog.alert(
|
||||
t('imageref.load_error', { error: err.message }),
|
||||
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||
'Bild konnte nicht geladen werden: ' + err.message,
|
||||
{ type: 'danger', title: 'Bildfehler' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -206,7 +206,7 @@ const ImageRef = {
|
||||
_createWidget(imageData, dataUrl) {
|
||||
WidgetManager.create('image', {
|
||||
id: imageData.id,
|
||||
title: imageData.label || t('imageref.title'),
|
||||
title: imageData.label || 'Bild-Referenz',
|
||||
x: imageData.x,
|
||||
y: imageData.y,
|
||||
width: imageData.width,
|
||||
@@ -249,14 +249,14 @@ const ImageRef = {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'imgref-img';
|
||||
img.src = dataUrl;
|
||||
img.alt = imageData.label || t('imageref.title');
|
||||
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 = t('imageref.replace');
|
||||
replaceBtn.textContent = 'Bild ersetzen';
|
||||
replaceBtn.addEventListener('click', async () => {
|
||||
const file = await this._pickFile();
|
||||
if (!file) return;
|
||||
@@ -266,8 +266,8 @@ const ImageRef = {
|
||||
this.renderBody(imageData, bodyEl, newDataUrl);
|
||||
} catch (err) {
|
||||
await HellionDialog.alert(
|
||||
t('imageref.load_error', { error: err.message }),
|
||||
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||
'Bild konnte nicht geladen werden: ' + err.message,
|
||||
{ type: 'danger', title: 'Bildfehler' }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -283,7 +283,7 @@ const ImageRef = {
|
||||
const label = document.createElement('input');
|
||||
label.className = 'imgref-label';
|
||||
label.type = 'text';
|
||||
label.placeholder = t('imageref.label_placeholder');
|
||||
label.placeholder = 'Beschriftung (optional)';
|
||||
label.maxLength = 100;
|
||||
label.value = imageData.label || '';
|
||||
|
||||
@@ -294,9 +294,9 @@ const ImageRef = {
|
||||
// Widget-Titel aktualisieren
|
||||
const entry = WidgetManager._widgets.get(imageData.id);
|
||||
if (entry) {
|
||||
const titleEl = entry.el.querySelector('.widget-title');
|
||||
if (titleEl) titleEl.textContent = text || t('imageref.title');
|
||||
entry.state.title = text || t('imageref.title');
|
||||
const titleEl = entry.el.querySelector('.widget-title-text');
|
||||
if (titleEl) titleEl.textContent = text || 'Bild-Referenz';
|
||||
entry.state.title = text || 'Bild-Referenz';
|
||||
}
|
||||
|
||||
this._debouncedSave();
|
||||
@@ -321,7 +321,7 @@ const ImageRef = {
|
||||
icon.textContent = '\uD83D\uDDBC\uFE0F';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = t('imageref.dropzone');
|
||||
text.textContent = 'Klicken oder Bild hierher ziehen';
|
||||
|
||||
dropzone.append(icon, text);
|
||||
|
||||
@@ -336,8 +336,8 @@ const ImageRef = {
|
||||
await this.save();
|
||||
} catch (err) {
|
||||
await HellionDialog.alert(
|
||||
t('imageref.load_error', { error: err.message }),
|
||||
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||
'Bild konnte nicht geladen werden: ' + err.message,
|
||||
{ type: 'danger', title: 'Bildfehler' }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -363,8 +363,8 @@ const ImageRef = {
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file || !file.type.startsWith('image/')) {
|
||||
await HellionDialog.alert(
|
||||
t('imageref.invalid_file'),
|
||||
{ type: 'warning', title: t('imageref.invalid_file.title') }
|
||||
'Bitte eine Bilddatei verwenden (PNG, JPG, WebP, etc.).',
|
||||
{ type: 'warning', title: 'Kein Bild' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -376,8 +376,8 @@ const ImageRef = {
|
||||
await this.save();
|
||||
} catch (err) {
|
||||
await HellionDialog.alert(
|
||||
t('imageref.load_error', { error: err.message }),
|
||||
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||
'Bild konnte nicht geladen werden: ' + err.message,
|
||||
{ type: 'danger', title: 'Bildfehler' }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -433,7 +433,7 @@ const ImageRef = {
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error(t('imageref.load_error', { error: 'unknown' })));
|
||||
reject(new Error('Bild konnte nicht geladen werden'));
|
||||
};
|
||||
|
||||
img.src = objectUrl;
|
||||
@@ -460,32 +460,41 @@ const ImageRef = {
|
||||
});
|
||||
}
|
||||
|
||||
// Widget-Lifecycle-Events
|
||||
// Close-Event abfangen
|
||||
const self = this;
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
const isImage = self._images.some(img => img.id === e.detail.id);
|
||||
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(e.detail.id);
|
||||
self.onClose(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
const isImage = self._images.some(img => img.id === e.detail.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) {
|
||||
self.save();
|
||||
await self.save();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
const imgData = self._images.find(img => img.id === e.detail.id);
|
||||
// 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(e.detail.id);
|
||||
const body = WidgetManager.getBody(id);
|
||||
if (body && body.children.length === 0) {
|
||||
const dataUrl = self._getSessionImage(e.detail.id);
|
||||
const dataUrl = self._getSessionImage(id);
|
||||
self.renderBody(imgData, body, dataUrl);
|
||||
}
|
||||
self.save();
|
||||
await self.save();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,15 +75,15 @@ const Notes = {
|
||||
async create(template) {
|
||||
if (this._notes.length >= this.MAX_NOTES) {
|
||||
await HellionDialog.alert(
|
||||
t('notes.limit_message', { max: this.MAX_NOTES }),
|
||||
{ type: 'warning', title: t('notes.limit_title') }
|
||||
'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' ? t('notes.checklist_title') : t('notes.default_title'),
|
||||
title: template === 'checklist' ? 'Checkliste' : 'Note',
|
||||
content: '',
|
||||
template: template,
|
||||
x: 120 + (this._notes.length * 30),
|
||||
@@ -138,7 +138,7 @@ const Notes = {
|
||||
_renderTextBody(noteData, bodyEl) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'widget-textarea';
|
||||
textarea.placeholder = t('notes.placeholder');
|
||||
textarea.placeholder = 'Notiz schreiben...';
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = noteData.content || '';
|
||||
textarea.maxLength = this.MAX_CHARS;
|
||||
@@ -204,7 +204,7 @@ const Notes = {
|
||||
const addInput = document.createElement('input');
|
||||
addInput.className = 'checklist-add-input';
|
||||
addInput.type = 'text';
|
||||
addInput.placeholder = t('notes.checklist_placeholder');
|
||||
addInput.placeholder = 'Neues Item...';
|
||||
addInput.maxLength = 100;
|
||||
|
||||
addInput.addEventListener('keydown', async (e) => {
|
||||
@@ -276,11 +276,11 @@ const Notes = {
|
||||
// Auto-Titel: "X/Y erledigt" falls kein manueller Titel
|
||||
const widgetEntry = WidgetManager._widgets.get(noteData.id);
|
||||
if (widgetEntry) {
|
||||
const defaultTitle = t('notes.checklist_progress', { done: done, total: total });
|
||||
const 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 === t('notes.checklist_title') || /^\d+\/\d+\s/.test(noteData.title)) {
|
||||
if (noteData.title === 'Checkliste' || /^\d+\/\d+ erledigt$/.test(noteData.title)) {
|
||||
noteData.title = defaultTitle;
|
||||
titleEl.textContent = defaultTitle;
|
||||
widgetEntry.state.title = defaultTitle;
|
||||
@@ -307,8 +307,8 @@ const Notes = {
|
||||
if (idx === -1) return;
|
||||
|
||||
const ok = await HellionDialog.confirm(
|
||||
t('notes.delete_confirm'),
|
||||
{ type: 'danger', title: t('notes.delete_title'), confirmText: t('notes.delete_button') }
|
||||
'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;
|
||||
|
||||
@@ -330,7 +330,7 @@ const Notes = {
|
||||
} else {
|
||||
md += noteData.content || '';
|
||||
}
|
||||
md += '\n\n---\n*' + t('notes.export_footer') + '*\n';
|
||||
md += '\n\n---\n*Exportiert aus Hellion Dashboard*\n';
|
||||
|
||||
const blob = new Blob([md], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -419,9 +419,9 @@ const Notes = {
|
||||
if (note.template === 'checklist') {
|
||||
const total = note.checklistItems ? note.checklistItems.length : 0;
|
||||
const done = note.checklistItems ? note.checklistItems.filter(i => i.checked).length : 0;
|
||||
preview.textContent = t('notes.checklist_progress', { done: done, total: total });
|
||||
preview.textContent = done + '/' + total + ' erledigt';
|
||||
} else {
|
||||
preview.textContent = (note.content || '').slice(0, 50) || t('notes.empty_preview');
|
||||
preview.textContent = (note.content || '').slice(0, 50) || 'Leer';
|
||||
}
|
||||
|
||||
// Actions
|
||||
@@ -430,7 +430,7 @@ const Notes = {
|
||||
|
||||
const btnExport = document.createElement('button');
|
||||
btnExport.className = 'notebook-slot-btn';
|
||||
btnExport.textContent = t('notes.export');
|
||||
btnExport.textContent = 'Export';
|
||||
btnExport.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.exportNote(note);
|
||||
@@ -470,7 +470,7 @@ const Notes = {
|
||||
slot.className = 'notebook-slot-empty';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = t('notes.create');
|
||||
label.textContent = '+ Note erstellen';
|
||||
slot.appendChild(label);
|
||||
|
||||
// Klick zeigt Typ-Auswahl
|
||||
@@ -485,7 +485,7 @@ const Notes = {
|
||||
|
||||
const btnText = document.createElement('button');
|
||||
btnText.className = 'notebook-type-btn';
|
||||
btnText.textContent = t('notes.text_type');
|
||||
btnText.textContent = '\u270E Freitext';
|
||||
btnText.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await this.create('text');
|
||||
@@ -494,7 +494,7 @@ const Notes = {
|
||||
|
||||
const btnCheck = document.createElement('button');
|
||||
btnCheck.className = 'notebook-type-btn';
|
||||
btnCheck.textContent = t('notes.checklist_type');
|
||||
btnCheck.textContent = '\u2611 Checkliste';
|
||||
btnCheck.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await this.create('checklist');
|
||||
|
||||
@@ -9,40 +9,44 @@ const Onboarding = {
|
||||
slides: [
|
||||
{
|
||||
hero: '\u2B21',
|
||||
titleKey: 'onboarding.s1.title',
|
||||
textKey: 'onboarding.s1.text'
|
||||
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',
|
||||
titleKey: 'onboarding.s2.title',
|
||||
featureKeys: ['onboarding.s2.f1', 'onboarding.s2.f2', 'onboarding.s2.f3', 'onboarding.s2.f4']
|
||||
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',
|
||||
titleKey: 'onboarding.s3.title',
|
||||
textKey: 'onboarding.s3.text',
|
||||
title: '8 handgefertigte Themes',
|
||||
text: 'Klicke auf den „Theme" Button im Header um dein Theme zu wählen. Jedes hat seinen eigenen Stil und Farbpalette.',
|
||||
showThemes: true
|
||||
},
|
||||
{
|
||||
hero: '\uD83E\uDDF0',
|
||||
titleKey: 'onboarding.s4.title',
|
||||
featureKeys: ['onboarding.s4.f1', 'onboarding.s4.f2', 'onboarding.s4.f3', 'onboarding.s4.f4', 'onboarding.s4.f5', 'onboarding.s4.f6']
|
||||
hero: '\u26A1',
|
||||
title: 'Weitere Features',
|
||||
features: [
|
||||
'Suchleiste mit Google, DuckDuckGo oder Bing',
|
||||
'Widget-Toolbar rechts \u2014 Notes und Checklisten erstellen',
|
||||
'Notebook-Sidebar \u00FCber den \u201ENote\u201C Button oder die Toolbar',
|
||||
'Funktioniert komplett offline \u2014 alles lokal gespeichert'
|
||||
]
|
||||
},
|
||||
{
|
||||
hero: '\uD83D\uDEE1\uFE0F',
|
||||
titleKey: 'onboarding.s5.title',
|
||||
textKey: 'onboarding.s5.text'
|
||||
},
|
||||
{
|
||||
hero: '\uD83C\uDFAE',
|
||||
titleKey: 'onboarding.s6.title',
|
||||
textKey: 'onboarding.s6.text',
|
||||
interactive: 'gaming-board'
|
||||
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: '\uD83D\uDE80',
|
||||
titleKey: 'onboarding.s7.title',
|
||||
textKey: 'onboarding.s7.text'
|
||||
title: 'Bereit!',
|
||||
text: 'Klicke auf \u201E+ Board\u201C um dein erstes Board zu erstellen, oder nutze den \u201EImport\u201C Button im Header um deine Browser-Lesezeichen zu importieren.'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -75,7 +79,7 @@ const Onboarding = {
|
||||
if (!isLast) {
|
||||
const skip = document.createElement('button');
|
||||
skip.className = 'onboarding-skip';
|
||||
skip.textContent = t('onboarding.skip');
|
||||
skip.textContent = '\u00DCberspringen';
|
||||
skip.addEventListener('click', () => this._finish());
|
||||
modal.appendChild(skip);
|
||||
}
|
||||
@@ -91,22 +95,22 @@ const Onboarding = {
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'onboarding-title';
|
||||
title.textContent = t(slide.titleKey);
|
||||
title.textContent = slide.title;
|
||||
slideEl.appendChild(title);
|
||||
|
||||
if (slide.textKey) {
|
||||
if (slide.text) {
|
||||
const text = document.createElement('div');
|
||||
text.className = 'onboarding-text';
|
||||
text.textContent = t(slide.textKey);
|
||||
text.textContent = slide.text;
|
||||
slideEl.appendChild(text);
|
||||
}
|
||||
|
||||
if (slide.featureKeys) {
|
||||
if (slide.features) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'onboarding-feature-list';
|
||||
slide.featureKeys.forEach(key => {
|
||||
slide.features.forEach(f => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = t(key);
|
||||
li.textContent = f;
|
||||
list.appendChild(li);
|
||||
});
|
||||
slideEl.appendChild(list);
|
||||
@@ -115,7 +119,7 @@ const Onboarding = {
|
||||
if (slide.showThemes) {
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'onboarding-theme-grid';
|
||||
const themeNames = ['Nebula', 'Crescent', 'Event Horizon', 'Merchantman', 'Julia & Jin', 'SC Sunset', 'Hellion HUD', 'Hellion Energy', 'Satisfactory', 'Avorion', 'Hellion Stealth'];
|
||||
const themeNames = ['Nebula', 'Crescent', 'Event Horizon', 'Merchantman', 'Julia & Jin', 'SC Sunset', 'Hellion HUD', 'Hellion Energy'];
|
||||
themeNames.forEach(name => {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'onboarding-theme-chip';
|
||||
@@ -148,7 +152,7 @@ const Onboarding = {
|
||||
if (this.currentSlide > 0) {
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.className = 'btn-secondary';
|
||||
backBtn.textContent = t('onboarding.back');
|
||||
backBtn.textContent = 'Zur\u00FCck';
|
||||
backBtn.addEventListener('click', () => {
|
||||
this.currentSlide--;
|
||||
this._render();
|
||||
@@ -156,36 +160,16 @@ const Onboarding = {
|
||||
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 = t('onboarding.no');
|
||||
noBtn.addEventListener('click', () => {
|
||||
this.currentSlide++;
|
||||
this._render();
|
||||
});
|
||||
|
||||
const yesBtn = document.createElement('button');
|
||||
yesBtn.className = 'btn-primary';
|
||||
yesBtn.textContent = t('onboarding.yes');
|
||||
yesBtn.addEventListener('click', async () => {
|
||||
await this._createGamingBoard();
|
||||
this.currentSlide++;
|
||||
this._render();
|
||||
});
|
||||
|
||||
nav.append(noBtn, yesBtn);
|
||||
} else if (isLast) {
|
||||
if (isLast) {
|
||||
const startBtn = document.createElement('button');
|
||||
startBtn.className = 'btn-primary';
|
||||
startBtn.textContent = t('onboarding.start');
|
||||
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 = t('onboarding.next');
|
||||
nextBtn.textContent = 'Weiter';
|
||||
nextBtn.addEventListener('click', () => {
|
||||
this.currentSlide++;
|
||||
this._render();
|
||||
@@ -197,34 +181,6 @@ const Onboarding = {
|
||||
modal.appendChild(footer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Gaming Starter Board erstellen
|
||||
* Vorbefuelltes Board mit Community-Links fuer Factory/Space Games
|
||||
*/
|
||||
async _createGamingBoard() {
|
||||
const gamingBoard = {
|
||||
id: uid(),
|
||||
title: '\uD83C\uDFAE Gaming',
|
||||
bookmarks: [
|
||||
{ id: uid(), title: 'Satisfactory Wiki', url: 'https://satisfactory.wiki.gg', desc: '' },
|
||||
{ id: uid(), title: 'Satisfactory Calculator', url: 'https://satisfactorytools.com', desc: '' },
|
||||
{ id: uid(), title: 'Factorio Wiki', url: 'https://wiki.factorio.com', desc: '' },
|
||||
{ id: uid(), title: 'Factorio Cheatsheet', url: 'https://factoriocheatsheet.com', desc: '' },
|
||||
{ id: uid(), title: 'Avorion Wiki', url: 'https://wiki.avorion.net', desc: '' },
|
||||
{ id: uid(), title: 'Minecraft Wiki', url: 'https://minecraft.wiki', desc: '' },
|
||||
{ id: uid(), title: 'Modrinth (Mods)', url: 'https://modrinth.com', desc: '' },
|
||||
{ id: uid(), title: 'Star Citizen Wiki', url: 'https://starcitizen.tools', desc: '' },
|
||||
{ id: uid(), title: 'UEX Corp (Trading)', url: 'https://uexcorp.space', desc: '' },
|
||||
{ id: uid(), title: 'Hellion TradeCenter', url: 'https://hellion-initiative.online/tradecenter', desc: t('onboarding.tradecenter_desc') }
|
||||
],
|
||||
blurred: false
|
||||
};
|
||||
|
||||
boards.push(gamingBoard);
|
||||
await saveBoards();
|
||||
renderBoards();
|
||||
},
|
||||
|
||||
/** Keyboard-Navigation */
|
||||
_bindKeyboard() {
|
||||
this._keyHandler = (e) => {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
# ⬡ 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.
|
||||
Opera GX priorisiert die eigene Speed Dial Seite und ignoriert `chrome_url_overrides`
|
||||
für entpackte Erweiterungen. Um das Hellion Dashboard trotzdem als New-Tab-Seite
|
||||
zu etablieren, kommen zwei zusätzliche Skripte zum Einsatz.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,8 +11,8 @@ der tatsächlich geholfen hat. Hier ist was dabei rausgekommen ist.
|
||||
| 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 |
|
||||
| Firefox | `chrome_url_overrides` (MV2) | Eigenes Manifest |
|
||||
| Opera / Opera GX | Blockiert durch Speed Dial | Workaround nötig |
|
||||
|
||||
---
|
||||
|
||||
@@ -24,36 +20,25 @@ der tatsächlich geholfen hat. Hier ist was dabei rausgekommen ist.
|
||||
|
||||
### `background.js` — Tab-Management
|
||||
|
||||
Überwacht Tab-Aktivitäten im Hintergrund und greift ein bevor Opera seine Startseite laden kann.
|
||||
Überwacht Tab-Aktivitäten im Hintergrund und greift ein bevor Opera seine Startseite lädt.
|
||||
|
||||
- Erkennt `opera://startpage/` und `chrome://startpage/`
|
||||
- Leitet per `chrome.tabs.update` auf `newtab.html` um
|
||||
- Prüft zusätzlich bei `onActivated`, weil Opera manche Tabs im Hintergrund lädt
|
||||
und der erste Redirect dann nicht greift
|
||||
- Prüft zusätzlich bei `onActivated` — auch im Hintergrund geladene Tabs werden sofort aktualisiert
|
||||
|
||||
### `redirect.js` — In-Page Redirect
|
||||
|
||||
Manche Opera-Systemprozesse sind so weit isoliert dass ein externer Eingriff
|
||||
nicht zuverlässig ankommt. Also nochmal von innen.
|
||||
Einige Opera-Systemprozesse sind so isoliert dass ein externer Eingriff nicht zuverlässig greift.
|
||||
|
||||
- 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.
|
||||
- Wird als Content Script in Opera-Startseiten-Bereiche injiziert
|
||||
- Löst den Redirect bei `document_start` aus — minimale Verzögerung, kein Flackern
|
||||
|
||||
---
|
||||
|
||||
## Datenschutz
|
||||
|
||||
Kein Tracking, keine Speicherung, keine externen Requests.
|
||||
Nur Standard-Browser-APIs, `chrome.tabs`, um zurückzubekommen was eigentlich
|
||||
standardmäßig funktionieren sollte.
|
||||
Ausschließlich Standard-Browser-APIs — `chrome.tabs` — um die Kontrolle über den New Tab zurückzugewinnen.
|
||||
|
||||
**100% lokal. 0% Analytics. Wie im gesamten Hellion NewTab Projekt.**
|
||||
|
||||
|
||||
@@ -23,20 +23,9 @@ function closeThemeModal() {
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft ob eine Background-URL sicher fuer CSS-Einbettung ist.
|
||||
* Erlaubt nur blob: und data:image/ Protokolle (aus File Upload).
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidBgUrl(url) {
|
||||
return typeof url === 'string' && url.length > 0 &&
|
||||
(url.startsWith('blob:') || url.startsWith('data:image/'));
|
||||
}
|
||||
|
||||
// ---- ACCORDION ----
|
||||
function initAccordion() {
|
||||
const defaultOpen = new Set(['widgets']);
|
||||
const defaultOpen = new Set(['appearance', 'behavior', 'widgets', 'data', 'help']);
|
||||
const sections = document.querySelectorAll('.settings-section[data-section]');
|
||||
|
||||
sections.forEach(section => {
|
||||
@@ -94,16 +83,10 @@ function applySettings() {
|
||||
const toolbarPosEl = document.getElementById('settingToolbarPos');
|
||||
if (toolbarPosEl) toolbarPosEl.value = settings.toolbarPos || 'right';
|
||||
|
||||
// Sprache (Dropdown-Wert setzen — I18n.init() übernimmt die eigentliche Anwendung)
|
||||
const langEl = document.getElementById('settingLanguage');
|
||||
if (langEl) langEl.value = settings.language || 'auto';
|
||||
|
||||
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
|
||||
|
||||
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
|
||||
if (settings.bgUrl) {
|
||||
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
||||
} else if (settings.bgUrl) {
|
||||
settings.bgUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,10 +164,6 @@ function bindSettingsEvents() {
|
||||
});
|
||||
document.getElementById('btnApplyBg').addEventListener('click', async () => {
|
||||
const url = document.getElementById('bgUrlInput').value.trim();
|
||||
if (url && !isValidBgUrl(url)) {
|
||||
await HellionDialog.alert(t('settings.bg_invalid_url'), { type: 'danger', title: t('settings.bg_invalid_url.title') });
|
||||
return;
|
||||
}
|
||||
settings.bgUrl = url;
|
||||
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
|
||||
await saveSettings();
|
||||
@@ -200,28 +179,16 @@ function bindSettingsEvents() {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async ev => {
|
||||
if (!isValidBgUrl(ev.target.result)) return;
|
||||
settings.bgUrl = ev.target.result;
|
||||
document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`;
|
||||
await saveSettings();
|
||||
};
|
||||
reader.onerror = () => {
|
||||
HellionDialog.alert(t('settings.file_read_error'), { type: 'danger', title: t('settings.file_read_error.title') });
|
||||
HellionDialog.alert('Fehler beim Lesen der Datei. Bitte eine andere Datei wählen.', { type: 'danger', title: 'Dateifehler' });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Sprach-Einstellung
|
||||
const languageEl = document.getElementById('settingLanguage');
|
||||
if (languageEl) {
|
||||
languageEl.value = settings.language || 'auto';
|
||||
languageEl.addEventListener('change', async (e) => {
|
||||
settings.language = e.target.value;
|
||||
setLanguage(e.target.value);
|
||||
await saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
// Toolbar-Position Setting
|
||||
const toolbarPosEl = document.getElementById('settingToolbarPos');
|
||||
if (toolbarPosEl) {
|
||||
@@ -242,18 +209,17 @@ function bindSettingsEvents() {
|
||||
// Reset All
|
||||
document.getElementById('btnResetAll').addEventListener('click', async () => {
|
||||
const ok = await HellionDialog.confirm(
|
||||
t('settings.reset_confirm'),
|
||||
{ type: 'danger', title: t('settings.reset_confirm.title'), confirmText: t('settings.reset_confirm.button') }
|
||||
'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 = [];
|
||||
settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false,
|
||||
hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula',
|
||||
showSearch: true, searchEngine: 'google', toolbarPos: 'right',
|
||||
imageRefEnabled: false, language: 'auto' };
|
||||
imageRefEnabled: false };
|
||||
await saveBoards();
|
||||
await saveSettings();
|
||||
setLanguage('auto');
|
||||
applySettings();
|
||||
renderBoards();
|
||||
closeSettings();
|
||||
|
||||
@@ -17,8 +17,7 @@ let settings = {
|
||||
showSearch: true,
|
||||
searchEngine: 'google',
|
||||
toolbarPos: 'right',
|
||||
imageRefEnabled: false,
|
||||
language: 'auto'
|
||||
imageRefEnabled: false
|
||||
};
|
||||
|
||||
function uid() {
|
||||
@@ -33,6 +32,15 @@ function escHtml(str) {
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function getFaviconUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=16`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultBoards() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ const Store = {
|
||||
chrome.storage.local.set({ [key]: value }, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error('Storage-Fehler:', chrome.runtime.lastError.message);
|
||||
HellionDialog.alert(t('storage.quota_full'), { type: 'danger', title: t('storage.quota_full.title') });
|
||||
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));
|
||||
return;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const Store = {
|
||||
resolve();
|
||||
} catch (e) {
|
||||
console.error('Storage-Fehler:', e.message);
|
||||
HellionDialog.alert(t('storage.quota_full'), { type: 'danger', title: t('storage.quota_full.title') });
|
||||
HellionDialog.alert('Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.', { type: 'danger', title: 'Speicher voll' });
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,14 @@
|
||||
============================================= */
|
||||
|
||||
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' },
|
||||
'nebula': { bg: 'assets/themes/bg-nebula.jpg' },
|
||||
'crescent': { bg: 'assets/themes/bg-crescent.jpg' },
|
||||
'event-horizon': { bg: 'assets/themes/bg-event-horizon.jpg' },
|
||||
'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' }
|
||||
'julia-jin': { bg: 'assets/themes/bg-julia-jin.png' },
|
||||
'sc-sunset': { bg: 'assets/themes/bg-sc-sunset.jpg' },
|
||||
'hellion-hud': { bg: 'assets/themes/bg-hellion-hud.png' },
|
||||
'hellion-energy': { bg: 'assets/themes/bg-hellion-energy.jpg' }
|
||||
};
|
||||
|
||||
function applyTheme(themeName, skipBgOverride) {
|
||||
|
||||
@@ -82,7 +82,7 @@ const Timer = {
|
||||
|
||||
WidgetManager.create('timer', {
|
||||
id: this.WIDGET_ID,
|
||||
title: t('timer.title'),
|
||||
title: 'Timer',
|
||||
x: saved.x || 600,
|
||||
y: saved.y || 80,
|
||||
width: saved.width || 260,
|
||||
@@ -190,7 +190,7 @@ const Timer = {
|
||||
const btnStart = document.createElement('button');
|
||||
btnStart.className = 'timer-ctrl-btn primary';
|
||||
btnStart.type = 'button';
|
||||
btnStart.textContent = t('timer.start');
|
||||
btnStart.textContent = 'Start';
|
||||
btnStart.addEventListener('click', () => {
|
||||
if (!this._running && this._remaining === 0) {
|
||||
this._applyInput();
|
||||
@@ -202,7 +202,7 @@ const Timer = {
|
||||
const btnPause = document.createElement('button');
|
||||
btnPause.className = 'timer-ctrl-btn';
|
||||
btnPause.type = 'button';
|
||||
btnPause.textContent = t('timer.pause');
|
||||
btnPause.textContent = 'Pause';
|
||||
btnPause.disabled = true;
|
||||
btnPause.addEventListener('click', () => this._pause());
|
||||
this._btnPause = btnPause;
|
||||
@@ -210,7 +210,7 @@ const Timer = {
|
||||
const btnReset = document.createElement('button');
|
||||
btnReset.className = 'timer-ctrl-btn danger';
|
||||
btnReset.type = 'button';
|
||||
btnReset.textContent = t('timer.reset');
|
||||
btnReset.textContent = 'Reset';
|
||||
btnReset.addEventListener('click', () => this._reset());
|
||||
this._btnReset = btnReset;
|
||||
|
||||
@@ -253,13 +253,13 @@ const Timer = {
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'timer-presets-title';
|
||||
title.textContent = t('timer.presets');
|
||||
title.textContent = 'Presets';
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'timer-preset-add';
|
||||
addBtn.type = 'button';
|
||||
addBtn.textContent = '+';
|
||||
addBtn.title = t('timer.save_preset');
|
||||
addBtn.title = 'Preset speichern';
|
||||
addBtn.addEventListener('click', () => this._showAddPreset(container));
|
||||
|
||||
header.append(title, addBtn);
|
||||
@@ -322,8 +322,8 @@ const Timer = {
|
||||
|
||||
if (this._presets.length >= this.MAX_PRESETS) {
|
||||
HellionDialog.alert(
|
||||
t('timer.limit_message', { max: this.MAX_PRESETS }),
|
||||
{ type: 'warning', title: t('timer.limit_title') }
|
||||
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_PRESETS + ' Presets speichern.',
|
||||
{ type: 'warning', title: 'Limit erreicht' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -334,8 +334,8 @@ const Timer = {
|
||||
const parsed = this._parseTimeInput(this._inputEl.value);
|
||||
if (parsed === 0) {
|
||||
HellionDialog.alert(
|
||||
t('timer.no_time_message'),
|
||||
{ type: 'info', title: t('timer.no_time_title') }
|
||||
'Gib zuerst eine Zeit ein, bevor du ein Preset speicherst.',
|
||||
{ type: 'info', title: 'Keine Zeit' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -347,13 +347,13 @@ const Timer = {
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.className = 'timer-add-input';
|
||||
nameInput.type = 'text';
|
||||
nameInput.placeholder = t('timer.preset_name_placeholder');
|
||||
nameInput.placeholder = 'Name...';
|
||||
nameInput.maxLength = 20;
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.className = 'timer-add-confirm';
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.textContent = t('timer.ok');
|
||||
confirmBtn.textContent = 'OK';
|
||||
|
||||
const doAdd = async () => {
|
||||
const name = nameInput.value.trim();
|
||||
@@ -508,9 +508,9 @@ const Timer = {
|
||||
_startTitleBlink() {
|
||||
this._originalTitle = document.title;
|
||||
this._blinkIntervalId = setInterval(() => {
|
||||
document.title = document.title === t('timer.finished_title')
|
||||
document.title = document.title === '[!] Timer abgelaufen'
|
||||
? this._originalTitle
|
||||
: t('timer.finished_title');
|
||||
: '[!] Timer abgelaufen';
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
@@ -521,7 +521,7 @@ const Timer = {
|
||||
if (this._blinkIntervalId) {
|
||||
clearInterval(this._blinkIntervalId);
|
||||
this._blinkIntervalId = null;
|
||||
document.title = this._originalTitle || t('timer.default_page_title');
|
||||
document.title = this._originalTitle || 'Hellion Dashboard';
|
||||
}
|
||||
this._finished = false;
|
||||
this._updateDisplay();
|
||||
@@ -534,7 +534,7 @@ const Timer = {
|
||||
_updateMuteBtn() {
|
||||
if (!this._muteBtn) return;
|
||||
this._muteBtn.textContent = this._muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
|
||||
this._muteBtn.title = this._muted ? t('timer.unmute') : t('timer.mute');
|
||||
this._muteBtn.title = this._muted ? 'Ton einschalten' : 'Ton ausschalten';
|
||||
this._muteBtn.classList.toggle('muted', this._muted);
|
||||
},
|
||||
|
||||
@@ -555,7 +555,7 @@ const Timer = {
|
||||
_updateControls() {
|
||||
if (this._btnStart) {
|
||||
this._btnStart.disabled = this._running;
|
||||
this._btnStart.textContent = this._finished ? t('timer.restart') : t('timer.start');
|
||||
this._btnStart.textContent = this._finished ? 'Neustart' : 'Start';
|
||||
}
|
||||
if (this._btnPause) {
|
||||
this._btnPause.disabled = !this._running;
|
||||
@@ -720,23 +720,32 @@ const Timer = {
|
||||
await this.open();
|
||||
}
|
||||
|
||||
// Widget-Lifecycle-Events
|
||||
// Close-Event abfangen
|
||||
const origClose = WidgetManager.close.bind(WidgetManager);
|
||||
const self = this;
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
const prevClose = WidgetManager.close;
|
||||
WidgetManager.close = function(id) {
|
||||
prevClose.call(WidgetManager, id);
|
||||
if (id === self.WIDGET_ID) {
|
||||
self.onClose();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
// 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;
|
||||
self.save();
|
||||
await self.save();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
// 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) {
|
||||
@@ -744,8 +753,8 @@ const Timer = {
|
||||
}
|
||||
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||
if (entry) self._bindKeyboard(entry.el);
|
||||
self.save();
|
||||
await self.save();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,27 +9,6 @@ const WidgetManager = {
|
||||
_topZ: 100,
|
||||
STORAGE_KEY: 'widgetStates',
|
||||
|
||||
/** @type {EventTarget} Internes Event-System fuer Widget-Lifecycle */
|
||||
_emitter: new EventTarget(),
|
||||
|
||||
/**
|
||||
* Event-Listener registrieren
|
||||
* @param {string} event - z.B. 'widget:close', 'widget:minimize', 'widget:open'
|
||||
* @param {Function} handler
|
||||
*/
|
||||
on(event, handler) {
|
||||
this._emitter.addEventListener(event, handler);
|
||||
},
|
||||
|
||||
/**
|
||||
* Event-Listener entfernen
|
||||
* @param {string} event
|
||||
* @param {Function} handler
|
||||
*/
|
||||
off(event, handler) {
|
||||
this._emitter.removeEventListener(event, handler);
|
||||
},
|
||||
|
||||
/**
|
||||
* Widget erstellen und in DOM einfuegen
|
||||
* @param {string} type - 'note'
|
||||
@@ -41,7 +20,7 @@ const WidgetManager = {
|
||||
const state = {
|
||||
id,
|
||||
type,
|
||||
title: config.title || t('notes.default_title'),
|
||||
title: config.title || 'Note',
|
||||
x: config.x || 120,
|
||||
y: config.y || 80,
|
||||
width: config.width || 280,
|
||||
@@ -52,7 +31,7 @@ const WidgetManager = {
|
||||
const el = this._buildDOM(state);
|
||||
document.body.appendChild(el);
|
||||
|
||||
this._widgets.set(id, { el, type, state, _minimizing: false });
|
||||
this._widgets.set(id, { el, type, state });
|
||||
this._initDrag(el);
|
||||
this._initResize(el);
|
||||
this.bringToFront(id);
|
||||
@@ -96,7 +75,7 @@ const WidgetManager = {
|
||||
title.addEventListener('blur', async () => {
|
||||
title.contentEditable = 'false';
|
||||
const newTitle = title.textContent.trim().slice(0, 20);
|
||||
title.textContent = newTitle || t('notes.default_title');
|
||||
title.textContent = newTitle || 'Note';
|
||||
const entry = this._widgets.get(state.id);
|
||||
if (entry) {
|
||||
entry.state.title = title.textContent;
|
||||
@@ -115,13 +94,13 @@ const WidgetManager = {
|
||||
|
||||
const btnMin = document.createElement('button');
|
||||
btnMin.className = 'widget-btn widget-minimize';
|
||||
btnMin.title = t('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 = t('widget.close');
|
||||
btnClose.title = 'Schließen';
|
||||
btnClose.textContent = '\u2715';
|
||||
btnClose.addEventListener('click', () => this.close(state.id));
|
||||
|
||||
@@ -165,47 +144,22 @@ const WidgetManager = {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry.el.remove();
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:close', { detail: { id } }));
|
||||
this._widgets.delete(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Widget minimieren (aus DOM verstecken, bleibt im Notebook).
|
||||
* Nutzt transitionend statt setTimeout — _minimizing Flag verhindert Race Condition
|
||||
* mit openWidget(). Fallback-Timer fuer prefers-reduced-motion / fehlende Transition.
|
||||
* 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._minimizing = true;
|
||||
entry.el.classList.add('widget-minimized');
|
||||
|
||||
const MINIMIZE_FALLBACK_MS = 350;
|
||||
|
||||
function onEnd(e) {
|
||||
if (e.target !== entry.el || e.propertyName !== 'opacity') return;
|
||||
clearTimeout(fallbackTimer);
|
||||
entry.el.removeEventListener('transitionend', onEnd);
|
||||
if (entry._minimizing) {
|
||||
entry.el.style.display = 'none';
|
||||
}
|
||||
entry._minimizing = false;
|
||||
}
|
||||
|
||||
entry.el.addEventListener('transitionend', onEnd);
|
||||
|
||||
const fallbackTimer = setTimeout(() => {
|
||||
entry.el.removeEventListener('transitionend', onEnd);
|
||||
if (entry._minimizing) {
|
||||
entry.el.style.display = 'none';
|
||||
entry._minimizing = false;
|
||||
}
|
||||
}, MINIMIZE_FALLBACK_MS);
|
||||
|
||||
setTimeout(() => {
|
||||
entry.el.style.display = 'none';
|
||||
}, 250);
|
||||
await this.save();
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -215,15 +169,14 @@ const WidgetManager = {
|
||||
async openWidget(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry._minimizing = false;
|
||||
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();
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||