Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d4708bf11 | |||
| f2b070e201 | |||
| 8176f91d4c | |||
| d68bb35e7a | |||
| 10c70f8bf9 | |||
| 28b9061756 | |||
| 60a1bec00d | |||
| 153db9c24d | |||
| 2e691b8b51 | |||
| 11419bd589 | |||
| 27fa4f53af | |||
| 8fdd46beec | |||
| 3dd9723271 | |||
| 2f23c13de1 | |||
| f5cebd8d34 | |||
| 10318008e6 | |||
| 50319f8ba9 | |||
| b71e8cde1b | |||
| 2487ac772f | |||
| 7be391de99 | |||
| cebf277a5d | |||
| 92c5b23b44 | |||
| 7f22627272 | |||
| 9b6515aab3 | |||
| 675e21d886 | |||
| 536e0771a4 | |||
| 02cdee76a8 | |||
| b6d347cd15 | |||
| 6704f4c955 | |||
| a3e21a760f | |||
| 82dd6e026a | |||
| 2430d65e3a | |||
| 30df93a4cc | |||
| b92ea5a1a4 | |||
| fde1fdd002 | |||
| 7cda3019c8 | |||
| 3de1dd3b8b | |||
| 63825cd393 | |||
| c6c0d5c468 | |||
| dbd209bc2b | |||
| 7900962c5a | |||
| 1bbdbdef1c | |||
| f07200cd8e | |||
| ab165d4f75 | |||
| 4a66015258 | |||
| d0f870ace1 | |||
| daea57a9df | |||
| f937f7c39c | |||
| 3ab8847f31 | |||
| 36335d3cc4 | |||
| 1b39ac863b | |||
| 522b177470 | |||
| f2d4e22b86 |
@@ -0,0 +1,13 @@
|
||||
name: Security
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
uses: JonKazama-Hellion/security-workflows/.gitea/workflows/security-scan.yml@main
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
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/ \
|
||||
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
|
||||
-x "*.git*" "dist/*" ".github/*" "src/js/opera/*"
|
||||
|
||||
- name: Create Firefox ZIP (Manifest V3)
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
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/ \
|
||||
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
|
||||
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*"
|
||||
mv manifest.chrome-backup.json manifest.json
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
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/ \
|
||||
manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ _locales/ \
|
||||
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.opera.json"
|
||||
mv manifest.chrome-backup.json manifest.json
|
||||
|
||||
|
||||
@@ -24,3 +24,6 @@ updates.json
|
||||
# Persönliche Backup-Dateien (nicht ins Repo)
|
||||
favorites_*.html
|
||||
*_backup*.json
|
||||
.mcp.json
|
||||
.claude
|
||||
.superpowers/
|
||||
@@ -6,6 +6,67 @@ All notable changes per version. Format based on [Keep a Changelog](https://keep
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ⬡ Hellion Dashboard v1.9.0
|
||||
# ⬡ Hellion Dashboard v2.0.0
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -10,7 +10,8 @@
|
||||
**No account. No subscription. No cloud. All data stays 100% local.**
|
||||
|
||||
A personal bookmark dashboard as a browser extension.
|
||||
Boards, drag & drop, 11 themes, search bar, widget system with notes, calculator, timer and more. All in the browser, all offline.
|
||||
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.
|
||||
|
||||
Developed by **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion.
|
||||
@@ -89,6 +90,12 @@ What you see is what's saved. No magic.
|
||||
| Avorion | Own work, screenshot from Avorion, Hellion Initiative ship | Hellion Online Media |
|
||||
| Hellion Stealth | Screenshot from Star Citizen by Cloud Imperium Games | Fan Content |
|
||||
|
||||
### Language Support (i18n)
|
||||
|
||||
- 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
|
||||
|
||||
### Onboarding & Dialogs
|
||||
|
||||
- 7-step welcome flow on first launch with widget explanation and optional gaming starter board
|
||||
@@ -103,7 +110,7 @@ What you see is what's saved. No magic.
|
||||
- Compact mode, shorten titles, search bar toggle, open links in new tab, descriptions, hide extra bookmarks
|
||||
- JSON export & import (backup & restore)
|
||||
- Onboarding repeatable
|
||||
- All UI labels in German (English coming in v2.1)
|
||||
- Language setting: German, English or auto-detect
|
||||
|
||||
---
|
||||
|
||||
@@ -223,10 +230,15 @@ hellion-newtab/
|
||||
├── SECURITY.md # Security policy and reporting
|
||||
├── DISCLAIMER.md # Disclaimer and legal
|
||||
│
|
||||
├── _locales/
|
||||
│ ├── de/messages.json # Manifest-level i18n (German)
|
||||
│ └── en/messages.json # Manifest-level i18n (English)
|
||||
│
|
||||
├── src/
|
||||
│ ├── js/
|
||||
│ │ ├── storage.js # Storage abstraction + quota 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
|
||||
@@ -272,7 +284,7 @@ hellion-newtab/
|
||||
|
||||
- **Zero Dependencies** — No npm, no build, no framework. Runs directly
|
||||
- **Privacy First** — All data local, no server contact
|
||||
- **Modular** — 15 JS files with clear responsibilities
|
||||
- **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)
|
||||
@@ -305,8 +317,8 @@ hellion-newtab/
|
||||
|
||||
```bash
|
||||
# Create a release:
|
||||
git tag v1.10.0
|
||||
git push origin v1.10.0
|
||||
git tag v2.0.0
|
||||
git push origin v2.0.0
|
||||
# → GitHub Action automatically creates release with ZIP files
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extName": { "message": "Hellion NewTab" },
|
||||
"extDesc": { "message": "Persönliches Bookmark-Dashboard mit Boards, Widgets und 11 Themes. Komplett lokal, keine Cloud, kein Tracking." }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extName": { "message": "Hellion NewTab" },
|
||||
"extDesc": { "message": "Personal bookmark dashboard with boards, widgets, and 11 themes. Local-only, no cloud, no tracking." }
|
||||
}
|
||||
+14
-8
@@ -27,21 +27,23 @@ HOM_NewTab_Project/
|
||||
│ ├── css/
|
||||
│ │ └── main.css # All styles, 11 themes, responsive breakpoints
|
||||
│ └── js/
|
||||
│ ├── dialog.js # Custom dialog system (alert, confirm)
|
||||
│ ├── storage.js # Storage abstraction layer
|
||||
│ ├── state.js # Global state, defaults, helpers
|
||||
│ ├── i18n.js # Internationalization (DE/EN, t() helper)
|
||||
│ ├── dialog.js # Custom dialog system (alert, confirm)
|
||||
│ ├── themes.js # Theme definitions & application (11 themes)
|
||||
│ ├── boards.js # Board/bookmark rendering & events
|
||||
│ ├── 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)
|
||||
│ ├── onboarding.js # First-run onboarding flow
|
||||
│ ├── widgets.js # Widget manager (registry, drag, resize)
|
||||
│ ├── notes.js # Notes/checklists (multi-instance widgets)
|
||||
│ ├── calculator.js # Calculator widget (single-instance)
|
||||
│ ├── timer.js # Timer/countdown widget (single-instance)
|
||||
│ ├── image-ref.js # Image reference widget (multi-instance)
|
||||
│ ├── 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)
|
||||
@@ -58,21 +60,23 @@ Each module has exactly one responsibility. Communication happens through global
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `dialog.js` | `HellionDialog.alert()` and `HellionDialog.confirm()` — custom styled dialogs that replace native browser popups. Loaded first so every other module can use it. |
|
||||
| `storage.js` | The **only** place that touches `chrome.storage` / `localStorage`. Everything else goes through `Store.get()` / `Store.set()`. |
|
||||
| `state.js` | Global `boards` and `settings` arrays, default values, `uid()`, `escHtml()`, `getFaviconUrl()`. |
|
||||
| `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). |
|
||||
| `onboarding.js` | Multi-slide first-run flow including the gaming starter board opt-in. |
|
||||
| `widgets.js` | Widget manager — creates DOM, handles drag/resize/z-index, provides registry. See [widget-schema.md](widget-schema.md). |
|
||||
| `notes.js` | Notes and checklists as widgets. Multi-instance (max 5). Notebook sidebar. Also handles widget toolbar events. |
|
||||
| `calculator.js` | Calculator widget. Single-instance. Shunting-yard expression parser — no `eval()`. |
|
||||
| `timer.js` | Timer/countdown widget. Single-instance. Presets, Web Audio API alarm, tab-title blink on completion. |
|
||||
| `image-ref.js` | Image reference widget. Multi-instance (max 3). Canvas API WebP conversion, sessionStorage for image data — cleared on browser close. |
|
||||
| `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. |
|
||||
|
||||
---
|
||||
@@ -106,21 +110,23 @@ DOMContentLoaded
|
||||
Scripts are loaded in `newtab.html` in dependency order. A module may only reference modules loaded before it — there is no bundler to handle this automatically.
|
||||
|
||||
```html
|
||||
<script src="src/js/dialog.js"></script>
|
||||
<script src="src/js/storage.js"></script>
|
||||
<script src="src/js/state.js"></script>
|
||||
<script src="src/js/i18n.js"></script>
|
||||
<script src="src/js/dialog.js"></script>
|
||||
<script src="src/js/themes.js"></script>
|
||||
<script src="src/js/boards.js"></script>
|
||||
<script src="src/js/drag.js"></script>
|
||||
<script src="src/js/boards.js"></script>
|
||||
<script src="src/js/settings.js"></script>
|
||||
<script src="src/js/search.js"></script>
|
||||
<script src="src/js/onboarding.js"></script>
|
||||
<script src="src/js/widgets.js"></script>
|
||||
<script src="src/js/notes.js"></script>
|
||||
<script src="src/js/calculator.js"></script>
|
||||
<script src="src/js/timer.js"></script>
|
||||
<script src="src/js/image-ref.js"></script>
|
||||
<script src="src/js/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>
|
||||
```
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,955 @@
|
||||
# Hellion NewTab v2.0.1 Hardening — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Harden v2.0.0 with security fixes, widget event-system refactoring, i18n completeness, and code quality improvements.
|
||||
|
||||
**Architecture:** Foundation-First — build the new widget event system first, then migrate widget modules onto it, then layer security, i18n, and quality fixes. Each task touches isolated files to avoid merge conflicts.
|
||||
|
||||
**Tech Stack:** Vanilla JavaScript ES2020, CSS Custom Properties, Browser Extension Manifest V3, no build step, no npm.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-16-hardening-v2.0.1-design.md`
|
||||
|
||||
**Testing:** No automated test framework. Each task includes manual browser-based verification steps. Load the extension in Chrome (`chrome://extensions` → Developer mode → Load unpacked) after each task.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Tasks | Changes |
|
||||
|---|---|---|
|
||||
| `src/js/widgets.js` | 1, 2 | Add event system (`_emitter`, `on`, `off`), dispatch events in `close`/`minimize`/`openWidget`, replace `setTimeout` with `transitionend` |
|
||||
| `src/js/calculator.js` | 3 | Replace monkey-patching (L692-728) with `WidgetManager.on()` listeners |
|
||||
| `src/js/timer.js` | 3 | Replace monkey-patching (L723-758) with `WidgetManager.on()` listeners |
|
||||
| `src/js/image-ref.js` | 3 | Replace monkey-patching (L463-498) with `WidgetManager.on()` listeners |
|
||||
| `src/js/settings.js` | 4 | Add `isValidBgUrl()`, validate in `applySettings()` and file upload + URL input handlers |
|
||||
| `src/js/data.js` | 5 | Add `isSafeUrl()`, immutable mapping, string length limits, Notes import via `Notes.init()` |
|
||||
| `src/js/state.js` | 6 | Remove `getFaviconUrl()` |
|
||||
| `src/js/boards.js` | 6 | Replace `<img>` favicon with local letter-div |
|
||||
| `src/css/main.css` | 6, 7 | Replace `.bm-favicon`/`.bm-favicon-fallback` with `.bm-favicon-local`, add `@supports not` fallback, add `--bg-solid-fallback` per theme |
|
||||
| `newtab.html` | 8 | Add 5x `data-i18n-title`, 3x `data-i18n` |
|
||||
| `src/js/i18n.js` | 8 | Add 10 new keys to `STRINGS.de` and `STRINGS.en` (8 i18n + 2 bgUrl validation) |
|
||||
| `src/js/app.js` | 9 | Store `setInterval` ID in variable |
|
||||
| `manifest.json` | 9 | Version bump to 2.0.1 |
|
||||
| `manifest.firefox.json` | 9 | Version bump to 2.0.1 |
|
||||
| `manifest.opera.json` | 9 | Version bump to 2.0.1 |
|
||||
| `CHANGELOG.md` | 9 | Add v2.0.1 entry |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Widget Event-System in WidgetManager
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/widgets.js:6-10` (add emitter + on/off)
|
||||
- Modify: `src/js/widgets.js:143-148` (close — dispatch event)
|
||||
|
||||
- [ ] **Step 1: Add event emitter and on/off methods to WidgetManager**
|
||||
|
||||
In `src/js/widgets.js`, add three new properties after `STORAGE_KEY: 'widgetStates',` (line 10):
|
||||
|
||||
```javascript
|
||||
/** @type {EventTarget} Internes Event-System fuer Widget-Lifecycle */
|
||||
_emitter: new EventTarget(),
|
||||
|
||||
/**
|
||||
* Event-Listener registrieren
|
||||
* @param {string} event - z.B. 'widget:close', 'widget:minimize', 'widget:open'
|
||||
* @param {Function} handler
|
||||
*/
|
||||
on(event, handler) {
|
||||
this._emitter.addEventListener(event, handler);
|
||||
},
|
||||
|
||||
/**
|
||||
* Event-Listener entfernen
|
||||
* @param {string} event
|
||||
* @param {Function} handler
|
||||
*/
|
||||
off(event, handler) {
|
||||
this._emitter.removeEventListener(event, handler);
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Dispatch `widget:close` event in close()**
|
||||
|
||||
Replace the `close` method (lines 143-148):
|
||||
|
||||
```javascript
|
||||
close(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry.el.remove();
|
||||
this._widgets.delete(id);
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:close', { detail: { id } }));
|
||||
},
|
||||
```
|
||||
|
||||
Note: The event fires AFTER `el.remove()` and `_widgets.delete()`. Listeners must not access the widget entry.
|
||||
|
||||
- [ ] **Step 3: Verify event system loads without errors**
|
||||
|
||||
Reload the extension in the browser. Open the console (`F12`). Verify:
|
||||
- No JavaScript errors on load
|
||||
- `WidgetManager.on` is a function (type `WidgetManager.on` in console)
|
||||
- `WidgetManager._emitter` is an EventTarget
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/widgets.js
|
||||
git commit -m "refactor(widgets): add EventTarget-based lifecycle event system
|
||||
|
||||
Add _emitter, on(), off() to WidgetManager. Dispatch widget:close event
|
||||
after close(). Foundation for removing monkey-patching from widget modules."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Minimize with transitionend + openWidget event dispatch
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/widgets.js:154-163` (minimize)
|
||||
- Modify: `src/js/widgets.js:169-180` (openWidget)
|
||||
|
||||
- [ ] **Step 1: Replace setTimeout with transitionend in minimize()**
|
||||
|
||||
Replace the `minimize` method (lines 154-163):
|
||||
|
||||
```javascript
|
||||
async minimize(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry.state.open = false;
|
||||
entry._minimizing = true;
|
||||
entry.el.classList.add('widget-minimized');
|
||||
|
||||
entry.el.addEventListener('transitionend', function onEnd(e) {
|
||||
if (e.target !== entry.el) return;
|
||||
entry.el.removeEventListener('transitionend', onEnd);
|
||||
if (entry._minimizing) {
|
||||
entry.el.style.display = 'none';
|
||||
}
|
||||
entry._minimizing = false;
|
||||
});
|
||||
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
|
||||
await this.save();
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add race-condition guard and event dispatch to openWidget()**
|
||||
|
||||
Replace the `openWidget` method (lines 169-180):
|
||||
|
||||
```javascript
|
||||
async openWidget(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry._minimizing = false;
|
||||
entry.state.open = true;
|
||||
entry.el.style.display = 'flex';
|
||||
requestAnimationFrame(() => {
|
||||
entry.el.classList.remove('widget-minimized');
|
||||
});
|
||||
this.bringToFront(id);
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
|
||||
await this.save();
|
||||
},
|
||||
```
|
||||
|
||||
Key change: `entry._minimizing = false` cancels any in-flight minimize transition.
|
||||
|
||||
- [ ] **Step 3: Verify minimize/open animation works**
|
||||
|
||||
Reload extension. Test:
|
||||
1. Create a note → minimize it → verify it fades out and disappears
|
||||
2. Click the note in the widget toolbar to reopen → verify it appears smoothly
|
||||
3. Rapid test: minimize → immediately reopen before animation ends → verify no display glitch (the race condition fix)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/widgets.js
|
||||
git commit -m "fix(widgets): replace setTimeout with transitionend in minimize
|
||||
|
||||
Fixes race condition where openWidget() during the 250ms timeout would
|
||||
be overridden. Uses _minimizing flag to cancel in-flight transitions.
|
||||
Dispatches widget:minimize and widget:open events."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Migrate Calculator, Timer, ImageRef to Event Listeners
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/calculator.js:692-728`
|
||||
- Modify: `src/js/timer.js:723-758`
|
||||
- Modify: `src/js/image-ref.js:463-498`
|
||||
|
||||
- [ ] **Step 1: Replace monkey-patching in calculator.js**
|
||||
|
||||
Replace lines 692-728 (the three monkey-patching blocks in `init()`) with:
|
||||
|
||||
```javascript
|
||||
// Widget-Lifecycle-Events
|
||||
const self = this;
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = false;
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = true;
|
||||
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||
if (body && body.children.length === 0) {
|
||||
self.renderBody(body);
|
||||
}
|
||||
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||
if (entry) self._bindKeyboard(entry.el);
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace monkey-patching in timer.js**
|
||||
|
||||
Replace lines 723-758 (the three monkey-patching blocks in `init()`) with:
|
||||
|
||||
```javascript
|
||||
// Widget-Lifecycle-Events
|
||||
const self = this;
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = false;
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = true;
|
||||
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||
if (body && body.children.length === 0) {
|
||||
self.renderBody(body);
|
||||
}
|
||||
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||
if (entry) self._bindKeyboard(entry.el);
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace monkey-patching in image-ref.js**
|
||||
|
||||
Replace lines 463-498 (the three monkey-patching blocks in `init()`) with:
|
||||
|
||||
```javascript
|
||||
// Widget-Lifecycle-Events
|
||||
const self = this;
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
const isImage = self._images.some(img => img.id === e.detail.id);
|
||||
if (isImage) {
|
||||
self.onClose(e.detail.id);
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
const isImage = self._images.some(img => img.id === e.detail.id);
|
||||
if (isImage) {
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
const imgData = self._images.find(img => img.id === e.detail.id);
|
||||
if (imgData) {
|
||||
const body = WidgetManager.getBody(e.detail.id);
|
||||
if (body && body.children.length === 0) {
|
||||
const dataUrl = self._getSessionImage(e.detail.id);
|
||||
self.renderBody(imgData, body, dataUrl);
|
||||
}
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify all three widget types work**
|
||||
|
||||
Reload extension. Test each widget type:
|
||||
|
||||
1. **Calculator:** Open → type a calculation → minimize → reopen → verify history is still there → close → reopen from toolbar
|
||||
2. **Timer:** Open → set a time → minimize → reopen → verify time is preserved → close
|
||||
3. **Image-Ref:** Enable in Settings → open image widget → add an image → minimize → reopen → verify image displays → close
|
||||
|
||||
Check console for any errors during all operations.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/calculator.js src/js/timer.js src/js/image-ref.js
|
||||
git commit -m "refactor(widgets): migrate Calculator, Timer, ImageRef to event listeners
|
||||
|
||||
Replace monkey-patching of WidgetManager.close/minimize/openWidget with
|
||||
WidgetManager.on() event listeners. Eliminates 3-deep closure chain."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Security — URL Validation in settings.js
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/settings.js:52-95` (applySettings)
|
||||
- Modify: `src/js/settings.js:166-175` (btnApplyBg handler)
|
||||
- Modify: `src/js/settings.js:181-194` (bgFileInput handler)
|
||||
|
||||
- [ ] **Step 1: Add isValidBgUrl() helper**
|
||||
|
||||
Add this function at the top of `settings.js`, after the `closeThemeModal()` function (after line 24):
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Prueft ob eine Background-URL sicher fuer CSS-Einbettung ist.
|
||||
* Erlaubt nur blob: und data:image/ Protokolle (aus File Upload).
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidBgUrl(url) {
|
||||
return typeof url === 'string' && url.length > 0 &&
|
||||
(url.startsWith('blob:') || url.startsWith('data:image/'));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add validation in applySettings()**
|
||||
|
||||
Replace lines 92-94:
|
||||
|
||||
```javascript
|
||||
if (settings.bgUrl) {
|
||||
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
|
||||
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
||||
} else if (settings.bgUrl) {
|
||||
// Ungueltige URL im Storage — bereinigen
|
||||
settings.bgUrl = '';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add validation in the URL-input handler (btnApplyBg)**
|
||||
|
||||
Replace lines 169-175:
|
||||
|
||||
```javascript
|
||||
document.getElementById('btnApplyBg').addEventListener('click', async () => {
|
||||
const url = document.getElementById('bgUrlInput').value.trim();
|
||||
settings.bgUrl = url;
|
||||
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
|
||||
await saveSettings();
|
||||
document.getElementById('bgInputRow').classList.add('hidden');
|
||||
});
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
document.getElementById('btnApplyBg').addEventListener('click', async () => {
|
||||
const url = document.getElementById('bgUrlInput').value.trim();
|
||||
if (url && !isValidBgUrl(url)) {
|
||||
await HellionDialog.alert(t('settings.bg_invalid_url'), { type: 'danger', title: t('settings.bg_invalid_url.title') });
|
||||
return;
|
||||
}
|
||||
settings.bgUrl = url;
|
||||
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
|
||||
await saveSettings();
|
||||
document.getElementById('bgInputRow').classList.add('hidden');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the file upload handler is already safe**
|
||||
|
||||
Read `settings.js:181-194`. The `FileReader.readAsDataURL(file)` produces a `data:image/...` string, which passes `isValidBgUrl()`. The handler at line 186 sets `settings.bgUrl = ev.target.result` — this is already valid output. No change needed here.
|
||||
|
||||
- [ ] **Step 5: Add i18n keys for the validation error dialog**
|
||||
|
||||
These keys will be added in Task 8 together with all other i18n keys. For now, note that we need:
|
||||
- `settings.bg_invalid_url` — "Nur lokale Bilder (Upload) sind als Hintergrund erlaubt." / "Only local images (upload) are allowed as background."
|
||||
- `settings.bg_invalid_url.title` — "Ungültige URL" / "Invalid URL"
|
||||
|
||||
- [ ] **Step 6: Verify background upload still works**
|
||||
|
||||
Reload extension. Test:
|
||||
1. Open Theme Modal → upload a local image → verify it displays as background
|
||||
2. Try entering `javascript:alert(1)` in the URL input → verify it's rejected with a dialog
|
||||
3. Reload → verify the uploaded background persists
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/settings.js
|
||||
git commit -m "fix(security): validate background URL before CSS injection
|
||||
|
||||
Add isValidBgUrl() that only allows blob: and data:image/ protocols.
|
||||
Applied in applySettings() and the manual URL input handler.
|
||||
Prevents CSS injection via manipulated bgUrl storage values."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Security + Quality — Data Import Hardening
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/data.js:33-127`
|
||||
|
||||
- [ ] **Step 1: Add isSafeUrl() helper at top of data.js**
|
||||
|
||||
Add after the `initDataButtons` function declaration (after line 6, before the function body):
|
||||
|
||||
Actually, add it inside the function before the event listeners, right after `if (!btnExport || !btnImport) return;` (after line 10):
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Prueft ob eine URL ein sicheres Protokoll hat.
|
||||
* Blockiert javascript:, data:, vbscript: etc.
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSafeUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the mutable board/bookmark filter with immutable mapping**
|
||||
|
||||
Replace lines 41-52 (the `validBoards` filter block):
|
||||
|
||||
```javascript
|
||||
const validBoards = data.boards
|
||||
.filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
|
||||
.map(b => ({
|
||||
id: b.id || uid(),
|
||||
title: String(b.title).slice(0, 100),
|
||||
blurred: !!b.blurred,
|
||||
bookmarks: b.bookmarks
|
||||
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
|
||||
.map(bm => ({
|
||||
id: bm.id || uid(),
|
||||
title: String(bm.title).slice(0, 200),
|
||||
url: bm.url,
|
||||
desc: String(bm.desc || '').slice(0, 500)
|
||||
}))
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the mutable notes filter with immutable mapping**
|
||||
|
||||
Replace lines 68-71 (the `importNotes` filter):
|
||||
|
||||
```javascript
|
||||
const importNotes = data.notes
|
||||
.filter(n => n && n.id && n.template)
|
||||
.map(n => ({
|
||||
id: n.id,
|
||||
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
|
||||
title: String(n.title || '').slice(0, 200),
|
||||
content: String(n.content || '').slice(0, 5000),
|
||||
x: typeof n.x === 'number' ? n.x : 120,
|
||||
y: typeof n.y === 'number' ? n.y : 80,
|
||||
width: typeof n.width === 'number' ? n.width : 280,
|
||||
height: typeof n.height === 'number' ? n.height : 220,
|
||||
open: n.open !== false,
|
||||
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace direct Notes._notes mutation with Notes.init()**
|
||||
|
||||
Replace lines 76-81:
|
||||
|
||||
```javascript
|
||||
if (toImport.length > 0) {
|
||||
const merged = [...existingNotes, ...toImport];
|
||||
existingWidgets.notes = merged;
|
||||
Notes._notes = merged;
|
||||
notesImported = toImport.length;
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
if (toImport.length > 0) {
|
||||
const merged = [...existingNotes, ...toImport];
|
||||
existingWidgets.notes = merged;
|
||||
notesImported = toImport.length;
|
||||
}
|
||||
```
|
||||
|
||||
Then after line 113 (`await Store.set('widgetStates', existingWidgets);`), add:
|
||||
|
||||
```javascript
|
||||
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
|
||||
if (notesImported > 0) await Notes.init();
|
||||
if (calcImported) await Calculator.load();
|
||||
if (timerImported) await Timer.load();
|
||||
```
|
||||
|
||||
And remove the direct mutations at lines 93 and 107:
|
||||
- Remove: `Calculator._history = existingWidgets.calculator.history;` (line 93)
|
||||
- Remove: `Timer._presets = existingWidgets.timer.presets;` (line 107)
|
||||
|
||||
- [ ] **Step 5: Verify import functionality**
|
||||
|
||||
Reload extension. Test:
|
||||
1. Export current data as JSON
|
||||
2. Edit the exported JSON: add a bookmark with `javascript:alert(1)` URL → import → verify the bad bookmark is silently skipped
|
||||
3. Import a normal JSON backup → verify boards, notes, calculator history, timer presets all appear correctly
|
||||
4. Verify no console errors
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/data.js
|
||||
git commit -m "fix(security): harden JSON import with URL validation and immutable mapping
|
||||
|
||||
Add isSafeUrl() to block javascript:/data: URLs in imported bookmarks.
|
||||
Replace mutable object mutation with immutable .map() and string length limits.
|
||||
Use Notes.init()/Calculator.load()/Timer.load() instead of direct _notes/_history
|
||||
mutation after import."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Remove Google Favicons — Local Letter Icons
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/state.js:36-43` (remove `getFaviconUrl`)
|
||||
- Modify: `src/js/boards.js:218-230` (replace favicon rendering)
|
||||
- Modify: `src/css/main.css:565-571` (replace CSS classes)
|
||||
|
||||
- [ ] **Step 1: Remove getFaviconUrl() from state.js**
|
||||
|
||||
Delete lines 36-43 in `src/js/state.js`:
|
||||
|
||||
```javascript
|
||||
function getFaviconUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=16`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace favicon rendering in boards.js**
|
||||
|
||||
Replace lines 218-230 in `src/js/boards.js`:
|
||||
|
||||
```javascript
|
||||
const favicon = document.createElement('img');
|
||||
favicon.className = 'bm-favicon';
|
||||
favicon.width = 14;
|
||||
favicon.height = 14;
|
||||
favicon.src = getFaviconUrl(bm.url);
|
||||
favicon.addEventListener('error', function() {
|
||||
this.classList.add('hidden');
|
||||
this.nextElementSibling.classList.remove('hidden');
|
||||
});
|
||||
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'bm-favicon-fallback hidden';
|
||||
fallback.textContent = bm.title.charAt(0).toUpperCase();
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
const favicon = document.createElement('div');
|
||||
favicon.className = 'bm-favicon-local';
|
||||
favicon.textContent = bm.title.charAt(0).toUpperCase();
|
||||
const hue = (bm.title.charCodeAt(0) * 137) % 360;
|
||||
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
|
||||
```
|
||||
|
||||
Also update the `appendChild` calls below. The old code appends both `favicon` and `fallback`:
|
||||
|
||||
Find the line that appends the fallback (should be near line 243-244):
|
||||
```javascript
|
||||
li.append(favicon, fallback, textDiv, deleteBtn);
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```javascript
|
||||
li.append(favicon, textDiv, deleteBtn);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace CSS classes in main.css**
|
||||
|
||||
Replace lines 565-571:
|
||||
|
||||
```css
|
||||
.bm-favicon { width: 14px; height: 14px; flex-shrink: 0; border-radius: 2px; opacity: 0.85; }
|
||||
.bm-favicon-fallback {
|
||||
width: 14px; height: 14px; flex-shrink: 0;
|
||||
background: var(--accent-dim); border-radius: 2px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 8px; color: var(--accent);
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```css
|
||||
.bm-favicon-local {
|
||||
width: 16px; height: 16px; flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 9px; font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify favicons display correctly**
|
||||
|
||||
Reload extension. Check:
|
||||
1. All bookmarks show a colored letter icon
|
||||
2. Different bookmark titles produce different colors
|
||||
3. The icons are aligned and properly sized in all themes
|
||||
4. No network requests to google.com in the Network tab (F12 → Network)
|
||||
5. No console errors about `getFaviconUrl`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/state.js src/js/boards.js src/css/main.css
|
||||
git commit -m "feat(privacy): replace Google Favicons with local letter icons
|
||||
|
||||
Remove getFaviconUrl() and all external network requests. Bookmarks now
|
||||
show a colored letter icon with deterministic hue based on title.
|
||||
Eliminates privacy leak and Brave Shields compatibility issues."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: backdrop-filter Fallback for Brave Shields
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/css/main.css` (add `--bg-solid-fallback` per theme + `@supports not` block)
|
||||
|
||||
- [ ] **Step 1: Add --bg-solid-fallback to each theme**
|
||||
|
||||
Add the variable to each theme's `[data-theme]` block. The value is an opaque version of `--bg-board`:
|
||||
|
||||
| Theme | Line | `--bg-solid-fallback` value |
|
||||
|---|---|---|
|
||||
| nebula | ~82 | `#0a060e` |
|
||||
| crescent | ~108 | `#0c0b08` |
|
||||
| event-horizon | ~137 | `#06040f` |
|
||||
| merchantman | ~163 | `#040d0d` |
|
||||
| julia-jin | ~189 | `#080c12` |
|
||||
| sc-sunset | ~216 | `#0e0808` |
|
||||
| hellion-hud | ~245 | `#04080c` |
|
||||
| hellion-energy | ~278 | `#040a08` |
|
||||
| satisfactory | ~310 | `#060a0c` |
|
||||
| avorion | ~341 | `#040c0a` |
|
||||
| hellion-stealth | ~371 | `#060a0e` |
|
||||
|
||||
Add `--bg-solid-fallback: <value>;` as the last variable in each theme block.
|
||||
|
||||
- [ ] **Step 2: Add @supports not block at the end of the general layout section**
|
||||
|
||||
Add after the existing board/widget styles, before the theme-specific sections (around line 75, before the first `[data-theme]` block):
|
||||
|
||||
```css
|
||||
/* Fallback fuer Browser die backdrop-filter blockieren (z.B. Brave Shields) */
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
.board,
|
||||
.widget,
|
||||
.settings-panel,
|
||||
.dialog-box,
|
||||
.theme-modal,
|
||||
.search-bar {
|
||||
background-color: var(--bg-solid-fallback, var(--bg-primary));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify fallback works**
|
||||
|
||||
Test in Brave with Shields set to aggressive. Or test by temporarily adding this CSS rule:
|
||||
```css
|
||||
.board { backdrop-filter: none !important; }
|
||||
```
|
||||
Verify that boards still have a visible background (opaque, not transparent).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/css/main.css
|
||||
git commit -m "fix(compat): add backdrop-filter fallback for Brave Shields
|
||||
|
||||
Add --bg-solid-fallback CSS variable to all 11 themes and a
|
||||
@supports not (backdrop-filter) block. UI remains usable when
|
||||
Brave Shields or strict fingerprinting settings block backdrop-filter."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Complete i18n Coverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `newtab.html:26-42` (add `data-i18n-title` to 5 header buttons)
|
||||
- Modify: `newtab.html:198, 215, 374` (add `data-i18n` to 3 setting buttons)
|
||||
- Modify: `src/js/i18n.js` (add 10 new keys — 8 from spec + 2 from Task 4)
|
||||
|
||||
- [ ] **Step 1: Add data-i18n-title to header buttons in newtab.html**
|
||||
|
||||
Line 26 — change:
|
||||
```html
|
||||
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)">
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)" data-i18n-title="header.import_title">
|
||||
```
|
||||
|
||||
Line 30 — change:
|
||||
```html
|
||||
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen">
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen" data-i18n-title="header.board_title">
|
||||
```
|
||||
|
||||
Line 34 — change:
|
||||
```html
|
||||
<button class="btn-icon" id="btnNote" title="Schnellnotiz">
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-icon" id="btnNote" title="Schnellnotiz" data-i18n-title="header.note_title">
|
||||
```
|
||||
|
||||
Line 38 — change:
|
||||
```html
|
||||
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme">
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme" data-i18n-title="header.theme_title">
|
||||
```
|
||||
|
||||
Line 42 — change:
|
||||
```html
|
||||
<button class="btn-icon" id="btnSettings" title="Einstellungen">
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-icon" id="btnSettings" title="Einstellungen" data-i18n-title="header.settings_title">
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add data-i18n to settings buttons in newtab.html**
|
||||
|
||||
Line 198 — change:
|
||||
```html
|
||||
<button class="btn-small" id="btnRestartOnboarding">Start</button>
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-small" id="btnRestartOnboarding" data-i18n="settings.onboarding_btn">Start</button>
|
||||
```
|
||||
|
||||
Line 215 — change:
|
||||
```html
|
||||
<button class="btn-danger" id="btnResetAll">Reset</button>
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-danger" id="btnResetAll" data-i18n="settings.reset_btn">Reset</button>
|
||||
```
|
||||
|
||||
Line 374 — change:
|
||||
```html
|
||||
<button class="btn-small" id="btnBgFile">Upload</button>
|
||||
```
|
||||
To:
|
||||
```html
|
||||
<button class="btn-small" id="btnBgFile" data-i18n="settings.bg_upload_btn">Upload</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add new keys to STRINGS.de in i18n.js**
|
||||
|
||||
Add these keys to the `STRINGS.de` object, in the appropriate sections:
|
||||
|
||||
In the Header section:
|
||||
```javascript
|
||||
'header.import_title': 'Bookmarks importieren (HTML)',
|
||||
'header.board_title': 'Neues Board hinzufügen',
|
||||
'header.note_title': 'Schnellnotiz',
|
||||
'header.theme_title': 'Darstellung & Theme',
|
||||
'header.settings_title': 'Einstellungen',
|
||||
```
|
||||
|
||||
In the Settings section:
|
||||
```javascript
|
||||
'settings.onboarding_btn': 'Start',
|
||||
'settings.reset_btn': 'Reset',
|
||||
'settings.bg_upload_btn': 'Upload',
|
||||
'settings.bg_invalid_url': 'Nur lokale Bilder (Upload) sind als Hintergrund erlaubt.',
|
||||
'settings.bg_invalid_url.title': 'Ungültige URL',
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add new keys to STRINGS.en in i18n.js**
|
||||
|
||||
Add the matching English keys to `STRINGS.en`:
|
||||
|
||||
In the Header section:
|
||||
```javascript
|
||||
'header.import_title': 'Import bookmarks (HTML)',
|
||||
'header.board_title': 'Add new board',
|
||||
'header.note_title': 'Quick note',
|
||||
'header.theme_title': 'Appearance & Theme',
|
||||
'header.settings_title': 'Settings',
|
||||
```
|
||||
|
||||
In the Settings section:
|
||||
```javascript
|
||||
'settings.onboarding_btn': 'Start',
|
||||
'settings.reset_btn': 'Reset',
|
||||
'settings.bg_upload_btn': 'Upload',
|
||||
'settings.bg_invalid_url': 'Only local images (upload) are allowed as background.',
|
||||
'settings.bg_invalid_url.title': 'Invalid URL',
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify translations**
|
||||
|
||||
Reload extension. Test:
|
||||
1. Set language to English → hover over header buttons → verify English tooltips
|
||||
2. Set language to German → hover → verify German tooltips
|
||||
3. Open Settings → verify "Start", "Reset", "Upload" buttons have `data-i18n` attributes (inspect in DevTools)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add newtab.html src/js/i18n.js
|
||||
git commit -m "fix(i18n): complete missing translations for toolbar tooltips and button texts
|
||||
|
||||
Add data-i18n-title to 5 header buttons, data-i18n to 3 settings buttons.
|
||||
Add 10 new keys to STRINGS.de and STRINGS.en including background URL
|
||||
validation error messages."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Version Bump, Changelog, Clock Cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/js/app.js:135`
|
||||
- Modify: `manifest.json:5`
|
||||
- Modify: `manifest.firefox.json` (version field)
|
||||
- Modify: `manifest.opera.json` (version field)
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1: Store clock interval ID in app.js**
|
||||
|
||||
Replace line 135 in `src/js/app.js`:
|
||||
|
||||
```javascript
|
||||
setInterval(tick, 1000);
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
const clockInterval = setInterval(tick, 1000);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Bump version in all three manifests**
|
||||
|
||||
In `manifest.json`, `manifest.firefox.json`, and `manifest.opera.json`, change:
|
||||
```json
|
||||
"version": "2.0.0",
|
||||
```
|
||||
To:
|
||||
```json
|
||||
"version": "2.0.1",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add CHANGELOG entry**
|
||||
|
||||
Add this block at the top of `CHANGELOG.md`, after the header and before the v2.0.0 entry:
|
||||
|
||||
```markdown
|
||||
### v2.0.1 — 16.04.2026
|
||||
|
||||
#### Security
|
||||
|
||||
- **Background URL validation** — Only `blob:` and `data:image/` protocols allowed in CSS `backgroundImage` (prevents CSS injection via manipulated storage)
|
||||
- **Import URL validation** — `javascript:`, `data:`, and other unsafe protocols are blocked during JSON import
|
||||
- **Immutable import mapping** — Imported boards, bookmarks, and notes are sanitized with explicit field selection and string length limits
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **Widget minimize race condition** — Replaced `setTimeout` with `transitionend` event; `openWidget()` during animation no longer causes display glitch
|
||||
- **Notes import mutation** — Import now uses `Notes.init()` instead of directly setting `Notes._notes`
|
||||
- **Complete i18n coverage** — 5 header button tooltips and 3 settings button texts now have `data-i18n` attributes (10 new translation keys)
|
||||
|
||||
#### Changed
|
||||
|
||||
- **Widget event system** — `WidgetManager` now dispatches `widget:close`, `widget:minimize`, `widget:open` CustomEvents via `EventTarget`. Calculator, Timer, and ImageRef use `WidgetManager.on()` instead of monkey-patching
|
||||
- **Local favicon icons** — Replaced Google Favicons API with local colored letter icons (deterministic hue per title). Zero external network requests, Brave Shields compatible
|
||||
- **backdrop-filter fallback** — `@supports not (backdrop-filter)` block with `--bg-solid-fallback` per theme for Brave Shields compatibility
|
||||
- **Clock interval cleanup** — `setInterval` ID stored in variable
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify everything**
|
||||
|
||||
Full manual test:
|
||||
1. Reload extension
|
||||
2. Verify version in `chrome://extensions` shows 2.0.1
|
||||
3. Open/close/minimize/reopen widgets of all types
|
||||
4. Switch language DE/EN — all tooltips translate
|
||||
5. Import/export JSON data
|
||||
6. Upload background image
|
||||
7. Check Network tab — zero external requests
|
||||
8. Check Console — zero errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/js/app.js manifest.json manifest.firefox.json manifest.opera.json CHANGELOG.md
|
||||
git commit -m "chore(release): bump version to v2.0.1 — hardening release
|
||||
|
||||
Security fixes, widget event system, local favicons, i18n completeness,
|
||||
backdrop-filter fallback, code quality improvements. See CHANGELOG.md."
|
||||
```
|
||||
@@ -0,0 +1,581 @@
|
||||
# Hellion NewTab — Calculator Upgrade Design
|
||||
|
||||
**Datum:** 2026-04-16
|
||||
**Autor:** Florian Wathling / Claude Code
|
||||
**Status:** Approved
|
||||
**Scope:** Calculator erweitern um Scientific, Unit-Converter und Game-Rechner (Satisfactory, Factorio, Stationeers)
|
||||
**Ziel-Version:** v2.1.0
|
||||
|
||||
---
|
||||
|
||||
## Kontext
|
||||
|
||||
Der Calculator ist aktuell ein reiner Grundrechenarten-Taschenrechner (720 Zeilen, Shunting-Yard Parser, 4x5 Button-Grid, History). Das Upgrade macht ihn zum zentralen Tool-Widget mit 6 Modi:
|
||||
|
||||
1. **Standard** (bestehend)
|
||||
2. **Scientific** (Wurzel, Potenz, Pi, Formel-Helfer)
|
||||
3. **Unit-Converter** (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche)
|
||||
4. **Satisfactory** (Items/Min, Overclock-Power, Maschinen-Rechner)
|
||||
5. **Factorio** (Assembler-Ratios, Belt-Throughput, Maschinen-Rechner)
|
||||
6. **Stationeers** (Idealgas, Furnace/Verbrennung, Solar/Batterie, Atmosphäre)
|
||||
|
||||
---
|
||||
|
||||
## Sektion 1: Architektur und Dateistruktur
|
||||
|
||||
### Datei-Aufteilung
|
||||
|
||||
```
|
||||
src/js/
|
||||
├── calculator.js # Core: Tab-System, Standard-Modus, erweiterter Shunting-Yard Parser
|
||||
├── calc-scientific.js # Scientific-Modus
|
||||
├── calc-converter.js # Unit-Converter
|
||||
├── calc-satisfactory.js # Satisfactory Calculator
|
||||
├── calc-factorio.js # Factorio Calculator
|
||||
└── calc-stationeers.js # Stationeers Calculator
|
||||
```
|
||||
|
||||
### Load-Order in newtab.html
|
||||
|
||||
```
|
||||
... → widgets.js → notes.js → calculator.js → calc-scientific.js → calc-converter.js →
|
||||
calc-satisfactory.js → calc-factorio.js → calc-stationeers.js → timer.js → ...
|
||||
```
|
||||
|
||||
Alle Mode-Dateien laden nach `calculator.js` und vor `timer.js`. Kein zirkulärer Dependency-Konflikt.
|
||||
|
||||
### Registrierungs-Pattern
|
||||
|
||||
Jede Mode-Datei registriert sich beim Calculator-Objekt:
|
||||
|
||||
```javascript
|
||||
Calculator.registerMode('scientific', {
|
||||
label: '\uD83D\uDCD0', // Icon
|
||||
shortName: 'Sci', // Tab-Label (3 Zeichen)
|
||||
titleKey: 'calculator.tab.scientific', // i18n-Key
|
||||
render(bodyEl) { /* UI aufbauen */ },
|
||||
destroy() { /* Cleanup, Event-Listener entfernen */ }
|
||||
});
|
||||
```
|
||||
|
||||
`calculator.js` bekommt:
|
||||
|
||||
```javascript
|
||||
_modes: new Map(),
|
||||
_activeMode: 'standard',
|
||||
|
||||
registerMode(name, config) {
|
||||
this._modes.set(name, config);
|
||||
},
|
||||
```
|
||||
|
||||
Die Tab-Leiste wird dynamisch aus `_modes` gebaut. Standard-Modus ist immer registriert (intern, nicht per externer Datei). Die anderen Modi kommen dazu wenn ihre Script-Datei geladen ist.
|
||||
|
||||
### Tab-Wechsel
|
||||
|
||||
```javascript
|
||||
switchMode(name) {
|
||||
const mode = this._modes.get(name);
|
||||
if (!mode) return;
|
||||
this._activeMode = name;
|
||||
const body = WidgetManager.getBody(this.WIDGET_ID);
|
||||
if (!body) return;
|
||||
|
||||
// Alten Modus aufräumen
|
||||
const oldMode = this._modes.get(this._previousMode);
|
||||
if (oldMode && oldMode.destroy) oldMode.destroy();
|
||||
|
||||
// Neuen Modus rendern
|
||||
body.textContent = '';
|
||||
mode.render(body);
|
||||
|
||||
// Tab-UI aktualisieren
|
||||
this._updateTabBar();
|
||||
|
||||
// State speichern
|
||||
this.save();
|
||||
}
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
Jeder Modus speichert seinen State als Sub-Key unter `calculator` im bestehenden `widgetStates`:
|
||||
|
||||
```javascript
|
||||
{
|
||||
calculator: {
|
||||
x: 400, y: 120, width: 320, height: 480,
|
||||
open: true,
|
||||
activeMode: 'standard',
|
||||
history: [{ expr: '42 × 7', result: '294' }],
|
||||
converter: { lastCategory: 'length', fromUnit: 'cm', toUnit: 'in' },
|
||||
satisfactory: { lastSubMode: 'itemsPerMin' },
|
||||
factorio: { lastSubMode: 'ratio', lastAssembler: 'asm3' },
|
||||
stationeers: { lastSubMode: 'gas' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Read-before-write Pattern bleibt: `const data = await Store.get(this.STORAGE_KEY) || {};`
|
||||
|
||||
---
|
||||
|
||||
## Sektion 2: Standard-Modus (Änderungen)
|
||||
|
||||
### Parser-Erweiterung
|
||||
|
||||
Der Shunting-Yard Parser wird um zwei Operationen erweitert:
|
||||
|
||||
**Potenz-Operator `^`:**
|
||||
- Binärer Operator mit höchster Precedence (über `*` und `/`)
|
||||
- Rechts-assoziativ: `2^3^2` = `2^(3^2)` = 512
|
||||
- Tokenizer erkennt `^` als `{ type: 'op', value: '^' }`
|
||||
- parseFactor() → parsePower() → parseFactor() (neue Precedence-Stufe)
|
||||
|
||||
**Wurzel-Funktion `sqrt`:**
|
||||
- Wird vom Scientific-Modus als `sqrt(` in die Expression eingefügt
|
||||
- Tokenizer erkennt `sqrt` als `{ type: 'func', value: 'sqrt' }`
|
||||
- parseFactor() prüft auf Functions vor Numbers
|
||||
|
||||
Die bestehende Operator-Hierarchie wird:
|
||||
```
|
||||
parseExpr: + -
|
||||
parseTerm: * / %
|
||||
parsePower: ^ ← NEU
|
||||
parseFactor: number | (expr) | func(expr) ← func NEU
|
||||
```
|
||||
|
||||
### Keine Änderungen am Standard-UI
|
||||
|
||||
Das 4x5 Button-Grid, History-Panel und Keyboard-Support bleiben identisch. Die Parser-Erweiterung ist rückwärtskompatibel (keine bestehende Expression bricht).
|
||||
|
||||
---
|
||||
|
||||
## Sektion 3: Scientific-Modus
|
||||
|
||||
### Zusätzliche Buttons
|
||||
|
||||
2 neue Reihen über dem Standard-Grid:
|
||||
|
||||
| Button | Wert | Aktion |
|
||||
|---|---|---|
|
||||
| √ | `sqrt(` | Unäre Funktion, öffnet Klammer |
|
||||
| x² | `^2` | Hängt `^2` an Expression |
|
||||
| xⁿ | `^` | Fügt Potenz-Operator ein |
|
||||
| π | `3.14159265359` | Konstante einfügen |
|
||||
| e | `2.71828182846` | Konstante einfügen |
|
||||
| ± | toggle | Vorzeichen des letzten Werts wechseln |
|
||||
|
||||
Darunter das Standard 4x5-Grid (C, Klammern, %, ÷, 0-9, Operatoren, =). Der Scientific-Modus nutzt den gleichen `_handleKey()`/`_calculate()`-Flow.
|
||||
|
||||
### Formel-Helfer
|
||||
|
||||
Ein Dropdown unter dem Button-Grid mit vorgefertigten Formeln:
|
||||
|
||||
| Formel | Eingabefelder | Berechnung |
|
||||
|---|---|---|
|
||||
| Kreis-Fläche | Radius (r) | `π × r²` |
|
||||
| Kreis-Umfang | Radius (r) | `2 × π × r` |
|
||||
| °C → °F | Temperatur | `(C × 9/5) + 32` |
|
||||
| °F → °C | Temperatur | `(F - 32) × 5/9` |
|
||||
| Pythagoras | a, b | `√(a² + b²)` |
|
||||
| Prozent-Wert | Wert, Prozent | `Wert × Prozent / 100` |
|
||||
|
||||
Jede Formel öffnet inline Eingabefelder + Live-Ergebnis. Nutzt `_formatResult()` für einheitliche Zahlenformatierung.
|
||||
|
||||
### Keyboard
|
||||
|
||||
Gleicher Keyboard-Support wie Standard-Modus, plus:
|
||||
- `p` → Pi einfügen
|
||||
- `e` → Euler einfügen (kein Konflikt: `e` ist im Standard nicht belegt, nur `c`/`C` und `Escape` sind Clear)
|
||||
|
||||
---
|
||||
|
||||
## Sektion 4: Unit-Converter
|
||||
|
||||
### UI-Aufbau
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ [Kategorie-Dropdown ▼]│
|
||||
│ │
|
||||
│ [123.45 ] [cm ▼] │
|
||||
│ ⇅ (Swap-Button) │
|
||||
│ [48.622 ] [in ▼] │
|
||||
│ │
|
||||
│ Schnellreferenz: │
|
||||
│ 1 cm = 0.3937 in │
|
||||
│ 1 in = 2.54 cm │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### Kategorien und Einheiten
|
||||
|
||||
| Kategorie | Einheiten | Basis-Einheit |
|
||||
|---|---|---|
|
||||
| Länge | mm, cm, m, km, in, ft, yd, mi | m |
|
||||
| Gewicht | mg, g, kg, t, oz, lb | g |
|
||||
| Temperatur | °C, °F, K | (Spezialfunktionen) |
|
||||
| Volumen | ml, L, m³, gal(US), gal(UK), ft³ | ml |
|
||||
| Geschwindigkeit | m/s, km/h, mph, kn | m/s |
|
||||
| Fläche | mm², cm², m², km², ha, acre, ft², in² | m² |
|
||||
|
||||
### Konvertierungs-Logik
|
||||
|
||||
Jede Einheit hat `toBase(value)` und `fromBase(value)`:
|
||||
|
||||
```javascript
|
||||
const LENGTH_UNITS = {
|
||||
mm: { toBase: v => v / 1000, fromBase: v => v * 1000 },
|
||||
cm: { toBase: v => v / 100, fromBase: v => v * 100 },
|
||||
m: { toBase: v => v, fromBase: v => v },
|
||||
km: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||
in: { toBase: v => v * 0.0254, fromBase: v => v / 0.0254 },
|
||||
ft: { toBase: v => v * 0.3048, fromBase: v => v / 0.3048 },
|
||||
yd: { toBase: v => v * 0.9144, fromBase: v => v / 0.9144 },
|
||||
mi: { toBase: v => v * 1609.344, fromBase: v => v / 1609.344 }
|
||||
};
|
||||
```
|
||||
|
||||
Temperatur bekommt eigene Funktionen (nicht linear):
|
||||
|
||||
```javascript
|
||||
const TEMP_CONVERSIONS = {
|
||||
'C_F': v => (v * 9/5) + 32,
|
||||
'C_K': v => v + 273.15,
|
||||
'F_C': v => (v - 32) * 5/9,
|
||||
'F_K': v => (v - 32) * 5/9 + 273.15,
|
||||
'K_C': v => v - 273.15,
|
||||
'K_F': v => (v - 273.15) * 9/5 + 32
|
||||
};
|
||||
```
|
||||
|
||||
### Verhalten
|
||||
|
||||
- Live-Update bei Eingabe (kein "Berechnen"-Button)
|
||||
- Swap-Button (⇅) tauscht Quell- und Ziel-Einheit
|
||||
- Schnellreferenz zeigt `1 [from] = x [to]` und umgekehrt
|
||||
- Kein Keyboard-Override (native `<input>` Felder)
|
||||
|
||||
### Storage
|
||||
|
||||
```javascript
|
||||
converter: { lastCategory: 'length', fromUnit: 'cm', toUnit: 'in' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sektion 5: Satisfactory Calculator
|
||||
|
||||
### Sub-Modi
|
||||
|
||||
Drei Buttons oben wählen den aktiven Rechner:
|
||||
|
||||
#### 5a: Items/Min
|
||||
|
||||
**Eingabefelder:**
|
||||
- Items per Craft (default: 1)
|
||||
- Craft Time in Sekunden (default: 4)
|
||||
- Clock Speed in % (default: 100)
|
||||
|
||||
**Formel:**
|
||||
```
|
||||
Output = (ItemsPerCraft × 60) / CraftTime × (ClockSpeed / 100)
|
||||
```
|
||||
|
||||
**Ausgabe:** `X.XX items/min`
|
||||
|
||||
#### 5b: Overclock Power
|
||||
|
||||
**Eingabefelder:**
|
||||
- Base Power in MW (default: 30)
|
||||
- Clock Speed in % (default: 100)
|
||||
|
||||
**Formeln:**
|
||||
```
|
||||
PowerUsage = BasePower × (ClockSpeed / 100) ^ 1.321928
|
||||
EnergyPerItem = (ClockSpeed / 100) ^ 0.321928
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Power Usage: X.X MW`
|
||||
- `Efficiency: ↓ X.X% per item` (nur bei ClockSpeed > 100)
|
||||
|
||||
#### 5c: Maschinen
|
||||
|
||||
**Eingabefelder:**
|
||||
- Target Output/Min (default: 60)
|
||||
- Items per Craft (default: 1)
|
||||
- Craft Time in Sekunden (default: 4)
|
||||
- Clock Speed in % (default: 100)
|
||||
- Base Power in MW (default: 30)
|
||||
|
||||
**Formeln:**
|
||||
```
|
||||
ItemsPerMin = (ItemsPerCraft × 60) / CraftTime × (ClockSpeed / 100)
|
||||
Machines = ceil(TargetOutput / ItemsPerMin)
|
||||
TotalPower = Machines × BasePower × (ClockSpeed / 100) ^ 1.321928
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Machines needed: X`
|
||||
- `Total Power: X.X MW`
|
||||
|
||||
### Verhalten
|
||||
|
||||
Alle Felder berechnen live. `<input type="number">` mit `step`-Attribut für sinnvolle Schrittweiten.
|
||||
|
||||
---
|
||||
|
||||
## Sektion 6: Factorio Calculator
|
||||
|
||||
### Sub-Modi
|
||||
|
||||
#### 6a: Assembler-Ratio
|
||||
|
||||
**Eingabefelder:**
|
||||
- Assembler-Dropdown: Assembler 1 (0.5), Assembler 2 (0.75), Assembler 3 (1.25)
|
||||
- Recipe Output Count (default: 1)
|
||||
- Recipe Time in Sekunden (default: 1)
|
||||
|
||||
**Formel:**
|
||||
```
|
||||
OutputPerSecond = RecipeOutput × CraftingSpeed / RecipeTime
|
||||
OutputPerMinute = OutputPerSecond × 60
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `X.XX items/s`
|
||||
- `X.XX items/min`
|
||||
|
||||
#### 6b: Belt-Throughput
|
||||
|
||||
**Eingabefelder:**
|
||||
- Belt-Dropdown: Yellow (15/s), Red (30/s), Blue (45/s)
|
||||
- Items consumed per second per machine (default: 1)
|
||||
|
||||
**Feste Werte:**
|
||||
|
||||
| Belt | Total (items/s) | Per Side (items/s) |
|
||||
|---|---|---|
|
||||
| Yellow | 15 | 7.5 |
|
||||
| Red | 30 | 15 |
|
||||
| Blue | 45 | 22.5 |
|
||||
|
||||
**Formel:**
|
||||
```
|
||||
MachinesPerBelt = floor(BeltThroughput / ItemsConsumedPerSec)
|
||||
Utilization = (ItemsConsumedPerSec × MachinesPerBelt) / BeltThroughput × 100
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Machines per belt: X`
|
||||
- `Belt utilization: X%`
|
||||
|
||||
#### 6c: Maschinen
|
||||
|
||||
**Eingabefelder:**
|
||||
- Assembler-Dropdown
|
||||
- Target Output/s (default: 10)
|
||||
- Recipe Output Count (default: 1)
|
||||
- Recipe Time in Sekunden (default: 1)
|
||||
|
||||
**Formel:**
|
||||
```
|
||||
OutputPerMachine = RecipeOutput × CraftingSpeed / RecipeTime
|
||||
Machines = ceil(TargetOutput / OutputPerMachine)
|
||||
TotalThroughput = Machines × OutputPerMachine
|
||||
BeltNeeded = kleinster Belt der TotalThroughput schafft
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Machines needed: X`
|
||||
- `Belt needed: [Color] (X% utilization)`
|
||||
|
||||
---
|
||||
|
||||
## Sektion 7: Stationeers Calculator
|
||||
|
||||
### Sub-Modi
|
||||
|
||||
Vier Buttons oben (statt drei wie bei den anderen Game-Rechnern).
|
||||
|
||||
#### 7a: Gas (Idealgas PV=nRT)
|
||||
|
||||
**Eingabefelder:**
|
||||
- Dropdown: Gesucht = P, V, n oder T
|
||||
- Die drei anderen Variablen als Eingabefelder
|
||||
|
||||
**Konstante:** R = 8314.46261815324 (Stationeers-spezifisch, Einheit: L·Pa / mol·K)
|
||||
|
||||
**Formeln:**
|
||||
```
|
||||
P = nRT / V
|
||||
V = nRT / P
|
||||
n = PV / RT
|
||||
T = PV / nR
|
||||
```
|
||||
|
||||
**Eingabe-Einheiten:**
|
||||
- P in kPa (wird intern × 1000 zu Pa)
|
||||
- V in Litern
|
||||
- T in Kelvin (Hilfstext zeigt °C-Äquivalent)
|
||||
- n in mol
|
||||
|
||||
#### 7b: Furnace / Verbrennung
|
||||
|
||||
**Eingabefelder:**
|
||||
- Fuel Ratio (0 bis 1, Anteil Brennstoff am Gesamtgas)
|
||||
- Start-Temperatur in Kelvin
|
||||
- Start-Druck in kPa
|
||||
|
||||
**Formeln:**
|
||||
```
|
||||
T_nach = (T_vor × specificHeat + fuel × 563452) / (specificHeat + fuel × 172.615)
|
||||
P_nach = P_vor × T_nach × (1 + 5.7 × fuel) / T_vor
|
||||
```
|
||||
|
||||
Wobei:
|
||||
- `fuel = min(ratioO2, ratioVolatile / 2)`
|
||||
- `specificHeat` = gewichtete Summe der Gas-Wärmekapazitäten
|
||||
- Vereinfachung: Fuel Ratio als einzelner Wert (0-1), `specificHeat(before)` wird aus reinem Fuel berechnet (61.9 J/mol·K für 1:2 O₂:H₂ Mischung)
|
||||
- 563452 = Energie pro Mol bei 95% Effizienz
|
||||
- 172.615 = 0.95 × (243.6 - 61.9)
|
||||
|
||||
**Validierung:**
|
||||
- Warnung wenn Fuel < 0.05 (unter 5% Minimum)
|
||||
- Warnung wenn Start-Druck < 10 kPa
|
||||
|
||||
**Ausgabe:**
|
||||
- `T after ignition: X K (X °C)`
|
||||
- `P after ignition: X kPa`
|
||||
|
||||
#### 7c: Solar / Batterie
|
||||
|
||||
**Eingabefelder:**
|
||||
- Anzahl Panels (default: 12)
|
||||
- Watt pro Panel (default: 500, Mond-Wert)
|
||||
- Tag-Länge in Sekunden (default: 600)
|
||||
- Nacht-Länge in Sekunden (default: 600)
|
||||
- Verbrauch in Watt (default: 2000)
|
||||
|
||||
**Formeln:**
|
||||
```
|
||||
Generation = Panels × WattsPerPanel
|
||||
Surplus = Generation - Consumption
|
||||
NightEnergy = Consumption × NightLength (in Watt-Sekunden)
|
||||
BatteriesNeeded = ceil(NightEnergy / 50000) (Station Battery = 50.000 Ws)
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Generation: X W`
|
||||
- `Surplus: X W` (rot wenn negativ)
|
||||
- `Night Energy: X Ws`
|
||||
- `Batteries needed: X`
|
||||
|
||||
#### 7d: Atmosphäre / Gas-Mischer
|
||||
|
||||
**Eingabefelder:**
|
||||
- Target Temperatur in Kelvin
|
||||
- Gas 1 Temperatur in Kelvin
|
||||
- Gas 2 Temperatur in Kelvin
|
||||
|
||||
**Formel:**
|
||||
```
|
||||
M1 = |T2 - T0| / (|T1 - T0| + |T2 - T0|)
|
||||
M2 = 1 - M1
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
- `Mixer Input 1: X.X%`
|
||||
- `Mixer Input 2: X.X%`
|
||||
|
||||
**Aufklappbare Wärmekapazität-Referenz:**
|
||||
|
||||
| Gas | Cp (J/mol·K) |
|
||||
|---|---|
|
||||
| O₂ | 21.1 |
|
||||
| H₂ | 20.4 |
|
||||
| CO₂ | 28.2 |
|
||||
| N₂ | 20.6 |
|
||||
| H₂O | 72.0 |
|
||||
| N₂O | 23.0 |
|
||||
| Pollutant | 24.8 |
|
||||
|
||||
---
|
||||
|
||||
## Sektion 8: UI, i18n und Widget-Sizing
|
||||
|
||||
### Tab-Leiste
|
||||
|
||||
Horizontale Leiste direkt unter dem Widget-Header. Immer sichtbar (kein Scrollen).
|
||||
|
||||
| Tab | Icon | Label |
|
||||
|---|---|---|
|
||||
| Standard | 🔢 | Std |
|
||||
| Scientific | 📐 | Sci |
|
||||
| Converter | ⚖️ | Unit |
|
||||
| Satisfactory | ⚙️ | SAT |
|
||||
| Factorio | 🏭 | FAC |
|
||||
| Stationeers | 🚀 | STA |
|
||||
|
||||
Aktiver Tab: `border-bottom: 2px solid var(--accent)`, Text in `var(--accent)`.
|
||||
Inaktive Tabs: `color: rgba(255,255,255,0.5)`.
|
||||
CSS-Klasse: `.calc-tab-bar` und `.calc-tab`.
|
||||
|
||||
### Widget-Sizing
|
||||
|
||||
- Standard-Modus Minimum: 280 × 400 px
|
||||
- Komplexe Modi (Scientific, Game-Rechner): Auto-Resize auf 320 × 480 px (falls aktuell kleiner)
|
||||
- User-Resize überschreibt Auto-Resize
|
||||
- Widget-System-Minimum bleibt 200 × 150 px
|
||||
|
||||
### i18n
|
||||
|
||||
Geschätzt ~100 neue Keys in `STRINGS.de` und `STRINGS.en`:
|
||||
|
||||
- 6 Tab-Labels
|
||||
- 6 Kategorie-Namen (Converter)
|
||||
- ~48 Einheiten-Langformen (Converter)
|
||||
- ~30 Feld-Labels (Game-Rechner)
|
||||
- ~10 Ergebnis-Labels
|
||||
|
||||
Einheiten-Abkürzungen (cm, kg, °C, kPa) werden nicht übersetzt.
|
||||
|
||||
### Keyboard
|
||||
|
||||
- Standard-Modus: Bestehender Keyboard-Support (0-9, +, -, *, /, Enter, Backspace, Escape)
|
||||
- Scientific-Modus: Gleicher Support + `p` (Pi), `^` (Potenz)
|
||||
- Converter und Game-Modi: Kein Custom-Keyboard (native `<input>` Felder)
|
||||
|
||||
---
|
||||
|
||||
## Betroffene Dateien (Gesamt)
|
||||
|
||||
| Datei | Änderung |
|
||||
|---|---|
|
||||
| `src/js/calculator.js` | Tab-System, registerMode(), switchMode(), Parser-Erweiterung (^, sqrt) |
|
||||
| `src/js/calc-scientific.js` | NEU: Scientific-Modus |
|
||||
| `src/js/calc-converter.js` | NEU: Unit-Converter |
|
||||
| `src/js/calc-satisfactory.js` | NEU: Satisfactory Calculator |
|
||||
| `src/js/calc-factorio.js` | NEU: Factorio Calculator |
|
||||
| `src/js/calc-stationeers.js` | NEU: Stationeers Calculator |
|
||||
| `src/css/main.css` | Tab-Bar Styles, Mode-spezifische Styles |
|
||||
| `src/js/i18n.js` | ~100 neue Keys (DE + EN) |
|
||||
| `newtab.html` | 5 neue `<script>` Tags in Load-Order |
|
||||
| `manifest.json` | Version → 2.1.0 |
|
||||
| `manifest.firefox.json` | Version → 2.1.0 |
|
||||
| `manifest.opera.json` | Version → 2.1.0 |
|
||||
| `CHANGELOG.md` | v2.1.0 Eintrag |
|
||||
|
||||
## Implementierungsreihenfolge
|
||||
|
||||
1. **Calculator Core** — Tab-System, registerMode(), switchMode(), Tab-Bar CSS
|
||||
2. **Parser-Erweiterung** — `^` Operator und `sqrt` Funktion
|
||||
3. **Scientific-Modus** — Buttons, Formel-Helfer, Registrierung
|
||||
4. **Unit-Converter** — Kategorien, Einheiten, Konvertierungs-Logik, UI
|
||||
5. **Satisfactory Calculator** — 3 Sub-Modi, Formeln, UI
|
||||
6. **Factorio Calculator** — 3 Sub-Modi, Formeln, UI
|
||||
7. **Stationeers Calculator** — 4 Sub-Modi, Formeln, UI
|
||||
8. **i18n** — Alle neuen Keys (DE + EN)
|
||||
9. **Version Bump** — Manifests, CHANGELOG
|
||||
@@ -0,0 +1,400 @@
|
||||
# Hellion NewTab v2.0.1 — Hardening Release Design
|
||||
|
||||
**Datum:** 2026-04-16
|
||||
**Autor:** Florian Wathling / Claude Code
|
||||
**Status:** Approved
|
||||
**Scope:** Security, Stability, i18n, Code Quality
|
||||
**Strategie:** Foundation First (Event-System zuerst, dann darauf aufbauen)
|
||||
|
||||
---
|
||||
|
||||
## Kontext
|
||||
|
||||
Umfassender Audit von v2.0.0 hat Findings in vier Kategorien ergeben:
|
||||
- 3 Sicherheitslücken (HOCH)
|
||||
- 2 Stabilitätsprobleme (Race Conditions)
|
||||
- 8 fehlende i18n-Attribute
|
||||
- 3 Code-Qualität-Items
|
||||
|
||||
Dieses Design beschreibt alle Fixes als zusammenhängendes Hardening-Release.
|
||||
|
||||
---
|
||||
|
||||
## Sektion 1: Widget Event-System
|
||||
|
||||
### Problem
|
||||
|
||||
Calculator (`calculator.js:692-728`), Timer (`timer.js:723-758`) und ImageRef (`image-ref.js:463-498`) überschreiben `WidgetManager.close`, `.minimize` und `.openWidget` durch Monkey-Patching in ihrer `init()`. Das erzeugt eine 3-stufige Closure-Kette pro Methode. Funktional korrekt, aber fragil und schwer debugbar.
|
||||
|
||||
### Lösung
|
||||
|
||||
WidgetManager bekommt ein internes Event-System basierend auf `EventTarget`.
|
||||
|
||||
**Neue API in `widgets.js`:**
|
||||
|
||||
```javascript
|
||||
_emitter: new EventTarget(),
|
||||
|
||||
on(event, handler) {
|
||||
this._emitter.addEventListener(event, handler);
|
||||
},
|
||||
|
||||
off(event, handler) {
|
||||
this._emitter.removeEventListener(event, handler);
|
||||
},
|
||||
```
|
||||
|
||||
**Events:**
|
||||
|
||||
| Event | Feuert nach | Detail |
|
||||
|---|---|---|
|
||||
| `widget:close` | `entry.el.remove()` + `_widgets.delete(id)` | `{ id }` | **Achtung:** Element bereits entfernt, Listener dürfen nicht auf Widget-Entry zugreifen |
|
||||
| `widget:minimize` | State-Änderung + Animation + Save | `{ id }` |
|
||||
| `widget:open` | State-Änderung + Display-Reset + Save | `{ id }` |
|
||||
|
||||
**Migration der Widget-Module:**
|
||||
|
||||
Das gesamte Monkey-Patching wird ersetzt durch `WidgetManager.on()` Aufrufe:
|
||||
|
||||
```javascript
|
||||
// Beispiel: Calculator.init()
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) self.onClose();
|
||||
});
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = false;
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = true;
|
||||
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||
if (body && body.children.length === 0) self.renderBody(body);
|
||||
self.save();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
ImageRef folgt dem gleichen Pattern, prüft aber per `self._images.some(img => img.id === id)` statt gegen eine feste WIDGET_ID.
|
||||
|
||||
**Load-Order:** Kein Problem. `widgets.js` wird vor allen Widget-Modulen geladen. Die Module rufen `WidgetManager.on()` in ihrer `init()` auf, die erst in `app.js` aufgerufen wird.
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `src/js/widgets.js` — Event-System hinzufügen, Events in close/minimize/openWidget dispatchen
|
||||
- `src/js/calculator.js` — Monkey-Patching (Z. 692-728) durch Event-Listener ersetzen
|
||||
- `src/js/timer.js` — Monkey-Patching (Z. 723-758) durch Event-Listener ersetzen
|
||||
- `src/js/image-ref.js` — Monkey-Patching (Z. 463-498) durch Event-Listener ersetzen
|
||||
|
||||
---
|
||||
|
||||
## Sektion 2: Minimize-Animation mit `transitionend`
|
||||
|
||||
### Problem
|
||||
|
||||
`WidgetManager.minimize()` (`widgets.js:154-163`) setzt `display: none` nach 250ms `setTimeout`. Wenn `openWidget()` in diesen 250ms aufgerufen wird, überschreibt der Timeout das `display: flex` wieder (Race Condition).
|
||||
|
||||
### Lösung
|
||||
|
||||
`setTimeout` wird durch `transitionend` Event ersetzt. Eine `_minimizing` Flag verhindert die Race Condition.
|
||||
|
||||
```javascript
|
||||
async minimize(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry.state.open = false;
|
||||
entry._minimizing = true;
|
||||
entry.el.classList.add('widget-minimized');
|
||||
|
||||
entry.el.addEventListener('transitionend', function onEnd(e) {
|
||||
if (e.target !== entry.el) return;
|
||||
entry.el.removeEventListener('transitionend', onEnd);
|
||||
if (entry._minimizing) {
|
||||
entry.el.style.display = 'none';
|
||||
}
|
||||
entry._minimizing = false;
|
||||
}, { once: false });
|
||||
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
|
||||
await this.save();
|
||||
},
|
||||
|
||||
async openWidget(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry._minimizing = false; // Race Condition verhindert
|
||||
entry.state.open = true;
|
||||
entry.el.style.display = 'flex';
|
||||
requestAnimationFrame(() => {
|
||||
entry.el.classList.remove('widget-minimized');
|
||||
});
|
||||
this.bringToFront(id);
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
|
||||
await this.save();
|
||||
},
|
||||
```
|
||||
|
||||
**Warum `_minimizing` Flag:** Robuster als `clearTimeout`, weil sie unabhängig von der CSS-Transition-Duration funktioniert.
|
||||
|
||||
**Fallback:** Falls `transitionend` nicht feuert (kein Transition definiert), bleibt das Widget sichtbar mit der Klasse. Akzeptabel, da alle Widgets in `main.css` eine Transition haben.
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `src/js/widgets.js` — `minimize()` und `openWidget()` umschreiben
|
||||
|
||||
---
|
||||
|
||||
## Sektion 3: Security Fixes
|
||||
|
||||
### 3a: URL-Injection in backgroundImage
|
||||
|
||||
**Datei:** `src/js/settings.js:93`
|
||||
**Problem:** `settings.bgUrl` wird unvalidiert in CSS-Template-Literal eingefügt.
|
||||
|
||||
**Fix:** Protokoll-Whitelist. Nur `blob:` und `data:image/` erlauben (die einzigen Protokolle die der Upload erzeugt).
|
||||
|
||||
```javascript
|
||||
function isValidBgUrl(url) {
|
||||
return typeof url === 'string' &&
|
||||
(url.startsWith('blob:') || url.startsWith('data:image/'));
|
||||
}
|
||||
```
|
||||
|
||||
Validierung an zwei Stellen: `applySettings()` und beim Speichern nach Upload.
|
||||
|
||||
### 3b: URL-Validierung beim JSON-Import
|
||||
|
||||
**Datei:** `src/js/data.js:45-49`
|
||||
**Problem:** Importierte Bookmark-URLs werden nicht auf Protokoll geprüft. `javascript:` oder `data:` URLs kommen durch.
|
||||
|
||||
**Fix:** Protokoll-Whitelist für importierte URLs.
|
||||
|
||||
```javascript
|
||||
function isSafeUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Integration in die Bookmark-Filter-Logik: `if (!bm || typeof bm.title !== 'string' || !isSafeUrl(bm.url)) return false;`
|
||||
|
||||
Ungültige Bookmarks werden still übersprungen.
|
||||
|
||||
### 3c: Objekt-Mutation im Import
|
||||
|
||||
**Datei:** `src/js/data.js:43-48`
|
||||
**Problem:** `b.id = b.id || uid()` mutiert das geparste JSON-Objekt direkt. Keine Längenvalidierung.
|
||||
|
||||
**Fix:** Immutable Mapping mit expliziter Feldauswahl und String-Längen-Limits.
|
||||
|
||||
```javascript
|
||||
.map(bm => ({
|
||||
id: bm.id || uid(),
|
||||
title: String(bm.title).slice(0, 200),
|
||||
url: bm.url,
|
||||
desc: String(bm.desc || '').slice(0, 500)
|
||||
}));
|
||||
```
|
||||
|
||||
Analog für Boards:
|
||||
|
||||
```javascript
|
||||
.map(b => ({
|
||||
id: b.id || uid(),
|
||||
title: String(b.title).slice(0, 100),
|
||||
blurred: !!b.blurred,
|
||||
bookmarks: /* bereits sanitized, siehe oben */
|
||||
}));
|
||||
```
|
||||
|
||||
Notes-Felder beim Import werden ebenfalls sanitized:
|
||||
|
||||
```javascript
|
||||
.filter(n => n && n.id && n.template)
|
||||
.map(n => ({
|
||||
id: n.id,
|
||||
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
|
||||
title: String(n.title || '').slice(0, 200),
|
||||
content: String(n.content || '').slice(0, 5000),
|
||||
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
|
||||
}));
|
||||
```
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `src/js/settings.js` — `isValidBgUrl()` + Validierung in `applySettings()`
|
||||
- `src/js/data.js` — `isSafeUrl()` + immutable Mapping + Längen-Limits
|
||||
|
||||
---
|
||||
|
||||
## Sektion 4: Lokale Favicons
|
||||
|
||||
### Problem
|
||||
|
||||
`getFaviconUrl()` (`state.js:36-43`) ruft Google Favicons API auf. Brave Shields blockiert das. Jeder Bookmark erzeugt einen fehlgeschlagenen Netzwerk-Request. Zusätzlich leakt jeder Hostname an Google.
|
||||
|
||||
### Lösung
|
||||
|
||||
Kein externer Request mehr. `getFaviconUrl()` wird entfernt. Bookmarks zeigen ein farbiges Buchstaben-Icon (erster Buchstabe des Titels).
|
||||
|
||||
**state.js:** `getFaviconUrl()` löschen.
|
||||
|
||||
**boards.js:** Statt `<img>` + Error-Fallback nur noch ein `<div>`:
|
||||
|
||||
```javascript
|
||||
const favicon = document.createElement('div');
|
||||
favicon.className = 'bm-favicon-local';
|
||||
favicon.textContent = bm.title.charAt(0).toUpperCase();
|
||||
// Deterministische Farbe pro Buchstabe
|
||||
const hue = (bm.title.charCodeAt(0) * 137) % 360;
|
||||
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
|
||||
```
|
||||
|
||||
Inline-Style für `backgroundColor` ist hier gerechtfertigt, weil der Wert dynamisch pro Bookmark berechnet wird. Restliche Styles (Größe, Border-Radius, Schrift) kommen aus CSS.
|
||||
|
||||
**main.css:** `.bm-favicon` und `.bm-favicon-fallback` ersetzen durch `.bm-favicon-local`.
|
||||
|
||||
### Was entfällt
|
||||
|
||||
- `getFaviconUrl()` in `state.js`
|
||||
- `<img class="bm-favicon">` Erzeugung in `boards.js`
|
||||
- Error-Listener für Favicon-Loads
|
||||
- `.bm-favicon` und `.bm-favicon-fallback` CSS-Regeln
|
||||
- Der einzige externe Netzwerk-Request der Extension
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `src/js/state.js` — `getFaviconUrl()` entfernen
|
||||
- `src/js/boards.js` — Favicon-Rendering umbauen
|
||||
- `src/css/main.css` — CSS-Klassen tauschen
|
||||
|
||||
---
|
||||
|
||||
## Sektion 5: i18n-Lücken schließen
|
||||
|
||||
### 5a: Toolbar-Buttons — fehlende `data-i18n-title`
|
||||
|
||||
Fünf Header-Buttons (`newtab.html:26-42`) haben hardcodierte deutsche `title`-Attribute.
|
||||
|
||||
| Button | Key | DE | EN |
|
||||
|---|---|---|---|
|
||||
| `#btnImport` | `header.import_title` | Bookmarks importieren (HTML) | Import bookmarks (HTML) |
|
||||
| `#btnAddBoard` | `header.board_title` | Neues Board hinzufügen | Add new board |
|
||||
| `#btnNote` | `header.note_title` | Schnellnotiz | Quick note |
|
||||
| `#btnTheme` | `header.theme_title` | Darstellung & Theme | Appearance & Theme |
|
||||
| `#btnSettings` | `header.settings_title` | Einstellungen | Settings |
|
||||
|
||||
**Fix:** `data-i18n-title` Attribute hinzufügen. `applyLanguage()` erkennt diese automatisch.
|
||||
|
||||
### 5b: Button-Texte ohne i18n
|
||||
|
||||
Drei Settings-Buttons haben hardcodierte Texte.
|
||||
|
||||
| Button | Key | DE | EN |
|
||||
|---|---|---|---|
|
||||
| `#btnRestartOnboarding` | `settings.onboarding_btn` | Start | Start |
|
||||
| `#btnResetAll` | `settings.reset_btn` | Reset | Reset |
|
||||
| `#btnBgFile` | `settings.bg_upload_btn` | Upload | Upload |
|
||||
|
||||
Aktuell in beiden Sprachen identisch, aber `data-i18n` wird für Konsistenz und zukünftige Erweiterbarkeit gesetzt.
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `newtab.html` — 5x `data-i18n-title`, 3x `data-i18n` hinzufügen
|
||||
- `src/js/i18n.js` — 8 neue Keys in `STRINGS.de` und `STRINGS.en`
|
||||
|
||||
---
|
||||
|
||||
## Sektion 6: Code-Qualität
|
||||
|
||||
### 6a: Notes-Mutation beim Import
|
||||
|
||||
**Datei:** `src/js/data.js:~79`
|
||||
**Problem:** `Notes._notes = merged` setzt das interne Array direkt, umgeht `Notes.save()`.
|
||||
|
||||
**Fix:** Nach dem Speichern in `widgetStates` wird `Notes.init()` aufgerufen statt das interne Array direkt zu manipulieren.
|
||||
|
||||
```javascript
|
||||
existingWidgets.notes = merged;
|
||||
await Store.set('widgetStates', existingWidgets);
|
||||
await Notes.init(); // Neu aus Storage laden + UI rendern
|
||||
```
|
||||
|
||||
### 6b: `backdrop-filter` Fallback
|
||||
|
||||
**Datei:** `src/css/main.css`
|
||||
**Problem:** 24 Stellen mit `backdrop-filter`. Brave Shields kann das blockieren.
|
||||
|
||||
**Fix:** Zentraler `@supports not` Block mit solidem Hintergrund-Fallback:
|
||||
|
||||
```css
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
.board,
|
||||
.widget,
|
||||
.settings-panel,
|
||||
.dialog-box,
|
||||
.theme-modal {
|
||||
background-color: var(--bg-solid-fallback);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Jedes Theme bekommt `--bg-solid-fallback` als deckende Variante der Glassmorphism-Farbe.
|
||||
|
||||
### 6c: Clock Interval Cleanup
|
||||
|
||||
**Datei:** `src/js/app.js:135`
|
||||
**Problem:** `setInterval(tick, 1000)` ID wird nicht gespeichert.
|
||||
|
||||
**Fix:** Interval-ID in Variable speichern. Niedrigste Priorität, da der Interval mit dem Tab stirbt.
|
||||
|
||||
```javascript
|
||||
let _clockInterval = null;
|
||||
_clockInterval = setInterval(tick, 1000);
|
||||
```
|
||||
|
||||
### Betroffene Dateien
|
||||
|
||||
- `src/js/data.js` — Notes-Import über `Notes.init()` statt direkter Mutation
|
||||
- `src/css/main.css` — `@supports not` Block + `--bg-solid-fallback` pro Theme
|
||||
- `src/js/app.js` — Interval-ID speichern
|
||||
|
||||
---
|
||||
|
||||
## Implementierungsreihenfolge (Foundation First)
|
||||
|
||||
1. **Event-System** in `widgets.js` bauen
|
||||
2. **Widget-Module** auf Events migrieren (`calculator.js`, `timer.js`, `image-ref.js`)
|
||||
3. **Minimize mit `transitionend`** in `widgets.js`
|
||||
4. **Security Fixes** in `settings.js` und `data.js`
|
||||
5. **Lokale Favicons** in `state.js`, `boards.js`, `main.css`
|
||||
6. **i18n-Lücken** in `newtab.html` und `i18n.js`
|
||||
7. **Code-Qualität** in `data.js`, `main.css`, `app.js`
|
||||
8. **Version Bump** auf 2.0.1 in allen drei Manifests + CHANGELOG
|
||||
|
||||
## Betroffene Dateien (Gesamt)
|
||||
|
||||
| Datei | Sektionen |
|
||||
|---|---|
|
||||
| `src/js/widgets.js` | 1, 2 |
|
||||
| `src/js/calculator.js` | 1 |
|
||||
| `src/js/timer.js` | 1 |
|
||||
| `src/js/image-ref.js` | 1 |
|
||||
| `src/js/settings.js` | 3a |
|
||||
| `src/js/data.js` | 3b, 3c, 6a |
|
||||
| `src/js/state.js` | 4 |
|
||||
| `src/js/boards.js` | 4 |
|
||||
| `src/js/i18n.js` | 5 |
|
||||
| `src/js/app.js` | 6c |
|
||||
| `src/css/main.css` | 4, 6b |
|
||||
| `newtab.html` | 5 |
|
||||
| `manifest.json` | 8 |
|
||||
| `manifest.firefox.json` | 8 |
|
||||
| `manifest.opera.json` | 8 |
|
||||
| `CHANGELOG.md` | 8 |
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hellion NewTab",
|
||||
"version": "1.11.1",
|
||||
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
|
||||
"name": "__MSG_extName__",
|
||||
"default_locale": "en",
|
||||
"version": "2.1.0",
|
||||
"description": "__MSG_extDesc__",
|
||||
"author": "Hellion Online Media - Florian Wathling",
|
||||
"homepage_url": "https://hellion-media.de",
|
||||
|
||||
@@ -33,6 +34,9 @@
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
},
|
||||
"icons": {
|
||||
"16": "assets/icons/icon16.png",
|
||||
"48": "assets/icons/icon48.png",
|
||||
|
||||
+7
-3
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hellion NewTab",
|
||||
"version": "1.11.1",
|
||||
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
|
||||
"name": "__MSG_extName__",
|
||||
"default_locale": "en",
|
||||
"version": "2.1.0",
|
||||
"description": "__MSG_extDesc__",
|
||||
"author": "Hellion Online Media - Florian Wathling",
|
||||
"homepage_url": "https://hellion-media.de",
|
||||
"chrome_url_overrides": {
|
||||
@@ -18,6 +19,9 @@
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
},
|
||||
"icons": {
|
||||
"16": "assets/icons/icon16.png",
|
||||
"48": "assets/icons/icon48.png",
|
||||
|
||||
+7
-3
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hellion Dashboard (GX Native)",
|
||||
"version": "1.11.1",
|
||||
"description": "Ersetzt die Opera GX Startseite durch dein persönliches, leistungsoptimiertes Hellion Dashboard. Schnell, sauber und werbefrei.",
|
||||
"name": "__MSG_extName__",
|
||||
"default_locale": "en",
|
||||
"version": "2.1.0",
|
||||
"description": "__MSG_extDesc__",
|
||||
"author": "Hellion Online Media - Florian Wathling",
|
||||
"homepage_url": "https://hellion-media.de",
|
||||
|
||||
@@ -39,6 +40,9 @@
|
||||
"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",
|
||||
|
||||
+125
-97
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -23,25 +23,25 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)">
|
||||
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)" data-i18n-title="header.import_title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Import
|
||||
<span data-i18n="header.import">Import</span>
|
||||
</button>
|
||||
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen">
|
||||
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen" data-i18n-title="header.board_title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Board
|
||||
<span data-i18n="header.board">Board</span>
|
||||
</button>
|
||||
<button class="btn-icon" id="btnNote" title="Schnellnotiz">
|
||||
<button class="btn-icon" id="btnNote" title="Schnellnotiz" data-i18n-title="header.note_title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Note
|
||||
<span data-i18n="header.note">Note</span>
|
||||
</button>
|
||||
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme">
|
||||
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme" data-i18n-title="header.theme_title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 100 20 4 4 0 01-1-7.9 1 1 0 011-.1h1a2 2 0 002-2V7a5 5 0 00-3-4.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="6" r="1.5"/><circle cx="17" cy="10" r="1.5"/><circle cx="9" cy="17" r="1.5"/></svg>
|
||||
Darstellung
|
||||
<span data-i18n="header.theme">Darstellung</span>
|
||||
</button>
|
||||
<button class="btn-icon" id="btnSettings" title="Einstellungen">
|
||||
<button class="btn-icon" id="btnSettings" title="Einstellungen" data-i18n-title="header.settings_title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||
Settings
|
||||
<span data-i18n="header.settings">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -49,11 +49,11 @@
|
||||
<!-- SEARCH BAR -->
|
||||
<div class="search-bar-wrapper" id="searchBarWrapper">
|
||||
<div class="search-bar">
|
||||
<button class="search-engine-toggle" id="searchEngineToggle" title="Suchmaschine wechseln">
|
||||
<button class="search-engine-toggle" id="searchEngineToggle" data-i18n-title="settings.search_engine_toggle" title="Suchmaschine wechseln">
|
||||
<span id="searchEngineIcon">G</span>
|
||||
</button>
|
||||
<input type="text" class="search-input" id="searchInput" placeholder="Search the web…" autocomplete="off" />
|
||||
<button class="search-submit" id="searchSubmit">
|
||||
<input type="text" class="search-input" id="searchInput" data-i18n-placeholder="search.placeholder" placeholder="Search the web…" autocomplete="off" />
|
||||
<button class="search-submit" id="searchSubmit" data-i18n-title="search.submit_title">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -61,22 +61,22 @@
|
||||
|
||||
<!-- WIDGET TOOLBAR -->
|
||||
<div class="widget-toolbar" id="widgetToolbar">
|
||||
<button class="widget-toolbar-btn" data-action="new-note" title="Note erstellen">
|
||||
<button class="widget-toolbar-btn" data-action="new-note" data-i18n-title="toolbar.note" title="Note erstellen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
</button>
|
||||
<button class="widget-toolbar-btn" data-action="new-checklist" title="Checkliste erstellen">
|
||||
<button class="widget-toolbar-btn" data-action="new-checklist" data-i18n-title="toolbar.checklist" title="Checkliste erstellen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
|
||||
</button>
|
||||
<button class="widget-toolbar-btn" data-action="calculator" title="Taschenrechner">
|
||||
<button class="widget-toolbar-btn" data-action="calculator" data-i18n-title="toolbar.calculator" title="Taschenrechner">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><circle cx="8" cy="10" r="0.5"/><circle cx="12" cy="10" r="0.5"/><circle cx="16" cy="10" r="0.5"/><circle cx="8" cy="14" r="0.5"/><circle cx="12" cy="14" r="0.5"/><circle cx="16" cy="14" r="0.5"/><circle cx="8" cy="18" r="0.5"/><circle cx="12" cy="18" r="0.5"/><circle cx="16" cy="18" r="0.5"/></svg>
|
||||
</button>
|
||||
<button class="widget-toolbar-btn" data-action="timer" title="Timer">
|
||||
<button class="widget-toolbar-btn" data-action="timer" data-i18n-title="toolbar.timer" title="Timer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3l2 2"/><path d="M19 3l-2 2"/><line x1="12" y1="1" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<button class="widget-toolbar-btn hidden" data-action="image-ref" title="Bild-Referenz">
|
||||
<button class="widget-toolbar-btn hidden" data-action="image-ref" data-i18n-title="toolbar.imageref" title="Bild-Referenz">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
||||
</button>
|
||||
<button class="widget-toolbar-btn" data-action="notebook" title="Alle Notes">
|
||||
<button class="widget-toolbar-btn" data-action="notebook" data-i18n-title="toolbar.notebook" title="Alle Notes">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -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">Notebook <span class="notebook-count" id="notebookCount">0 / 5</span></span>
|
||||
<button class="btn-close" id="btnCloseNotebook">✕</button>
|
||||
<span class="notebook-header-title"><span data-i18n="notebook.title">Notebook</span> <span class="notebook-count" id="notebookCount">0 / 5</span></span>
|
||||
<button class="btn-close" id="btnCloseNotebook" data-i18n-title="dialog.close">✕</button>
|
||||
</div>
|
||||
<div class="notebook-slots" id="notebookSlots">
|
||||
<!-- dynamisch via JS -->
|
||||
@@ -105,32 +105,53 @@
|
||||
<div class="panel-overlay" id="settingsOverlay"></div>
|
||||
<aside class="settings-panel" id="settingsPanel">
|
||||
<div class="panel-header">
|
||||
<span>Einstellungen</span>
|
||||
<button class="btn-close" id="btnCloseSettings">✕</button>
|
||||
<span data-i18n="settings.title">Einstellungen</span>
|
||||
<button class="btn-close" id="btnCloseSettings" data-i18n-title="dialog.close">✕</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<!-- SPRACHE -->
|
||||
<section class="settings-section" data-section="language">
|
||||
<button class="settings-section-title" type="button">
|
||||
<span class="section-chevron">▸</span>
|
||||
<span data-i18n="settings.section.display">DARSTELLUNG</span>
|
||||
</button>
|
||||
<div class="section-content">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label" data-i18n="settings.language">Sprache</span>
|
||||
<span class="setting-desc" data-i18n="settings.language.desc">Anzeigesprache wählen</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>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- WIDGETS -->
|
||||
<section class="settings-section" data-section="widgets">
|
||||
<button class="settings-section-title" type="button">
|
||||
<span class="section-chevron">▸</span>
|
||||
WIDGETS
|
||||
<span data-i18n="settings.section.widgets">WIDGETS</span>
|
||||
</button>
|
||||
<div class="section-content">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Toolbar-Position</span>
|
||||
<span class="setting-desc">Widget-Toolbar links oder rechts anzeigen</span>
|
||||
<span class="setting-label" data-i18n="settings.toolbar_pos">Toolbar-Position</span>
|
||||
<span class="setting-desc" data-i18n="settings.toolbar_pos.desc">Widget-Toolbar links oder rechts anzeigen</span>
|
||||
</div>
|
||||
<select class="select-input" id="settingToolbarPos">
|
||||
<option value="right" selected>Rechts</option>
|
||||
<option value="left">Links</option>
|
||||
<option value="right" selected data-i18n="settings.toolbar_pos.right">Rechts</option>
|
||||
<option value="left" data-i18n="settings.toolbar_pos.left">Links</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Bild-Referenz Widgets</span>
|
||||
<span class="setting-desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
|
||||
<span class="setting-label" data-i18n="settings.image_ref">Bild-Referenz Widgets</span>
|
||||
<span class="setting-desc" data-i18n="settings.image_ref.desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="settingImageRef">
|
||||
@@ -144,37 +165,37 @@
|
||||
<section class="settings-section" data-section="data">
|
||||
<button class="settings-section-title" type="button">
|
||||
<span class="section-chevron">▸</span>
|
||||
DATEN & HILFE
|
||||
<span data-i18n="settings.section.data">DATEN & HILFE</span>
|
||||
</button>
|
||||
<div class="section-content">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Backup exportieren</span>
|
||||
<span class="setting-desc">Alle Boards, Notes und Einstellungen als JSON sichern</span>
|
||||
<span class="setting-label" data-i18n="settings.export">Backup exportieren</span>
|
||||
<span class="setting-desc" data-i18n="settings.export.desc">Alle Boards, Notes und Einstellungen als JSON sichern</span>
|
||||
</div>
|
||||
<button class="btn-small" id="btnExportJSON">Export</button>
|
||||
<button class="btn-small" id="btnExportJSON" data-i18n="settings.export.btn">Export</button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Backup importieren</span>
|
||||
<span class="setting-desc">JSON-Backup wiederherstellen</span>
|
||||
<span class="setting-label" data-i18n="settings.import">Backup importieren</span>
|
||||
<span class="setting-desc" data-i18n="settings.import.desc">JSON-Backup wiederherstellen</span>
|
||||
</div>
|
||||
<button class="btn-small" id="btnImportJSON">Import</button>
|
||||
<button class="btn-small" id="btnImportJSON" data-i18n="header.import">Import</button>
|
||||
<input type="file" id="jsonImportInput" accept=".json" class="hidden" />
|
||||
</div>
|
||||
<div class="setting-row" id="browserImportRow">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Browser-Lesezeichen</span>
|
||||
<span class="setting-desc">Lesezeichen direkt aus dem Browser importieren</span>
|
||||
<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">Import</button>
|
||||
<button class="btn-small" id="btnBrowserImport" data-i18n="header.import">Import</button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Onboarding wiederholen</span>
|
||||
<span class="setting-desc">Willkommens-Tour erneut anzeigen</span>
|
||||
<span class="setting-label" data-i18n="settings.onboarding">Onboarding wiederholen</span>
|
||||
<span class="setting-desc" data-i18n="settings.onboarding.desc">Willkommens-Tour erneut anzeigen</span>
|
||||
</div>
|
||||
<button class="btn-small" id="btnRestartOnboarding">Start</button>
|
||||
<button class="btn-small" id="btnRestartOnboarding" data-i18n="settings.onboarding_btn">Start</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -183,15 +204,15 @@
|
||||
<section class="settings-section" data-section="danger">
|
||||
<button class="settings-section-title danger" type="button">
|
||||
<span class="section-chevron">▸</span>
|
||||
DANGER ZONE
|
||||
<span data-i18n="settings.section.danger">DANGER ZONE</span>
|
||||
</button>
|
||||
<div class="section-content">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Alles zurücksetzen</span>
|
||||
<span class="setting-desc">Löscht alle Boards, Notes und Einstellungen</span>
|
||||
<span class="setting-label" data-i18n="settings.reset">Alles zurücksetzen</span>
|
||||
<span class="setting-desc" data-i18n="settings.reset.desc">Löscht alle Boards, Notes und Einstellungen</span>
|
||||
</div>
|
||||
<button class="btn-danger" id="btnResetAll">Reset</button>
|
||||
<button class="btn-danger" id="btnResetAll" data-i18n="settings.reset_btn">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -201,13 +222,13 @@
|
||||
<!-- ABOUT — fixiert am unteren Rand -->
|
||||
<div class="panel-footer">
|
||||
<div class="about-block">
|
||||
<div class="about-logo">⬡ HELLION NEWTAB</div>
|
||||
<div class="about-version">Version 1.11.1 · by Hellion Online Media</div>
|
||||
<div class="about-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>
|
||||
Impressum
|
||||
<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>
|
||||
@@ -218,26 +239,26 @@
|
||||
<div class="about-divider"></div>
|
||||
|
||||
<div class="about-info-row">
|
||||
<span class="about-info-label">Entwickler</span>
|
||||
<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">Unternehmen</span>
|
||||
<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">Lizenz</span>
|
||||
<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">Datenspeicherung</span>
|
||||
<span class="about-info-value">100% lokal · Kein Server · Kein Account</span>
|
||||
<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">Bug Report / Feedback</span>
|
||||
<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
|
||||
@@ -245,7 +266,7 @@
|
||||
</div>
|
||||
|
||||
<div class="about-bugreport">
|
||||
<span class="about-info-label about-info-label-block">Support</span>
|
||||
<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
|
||||
@@ -253,7 +274,7 @@
|
||||
</div>
|
||||
|
||||
<div class="about-browsers">
|
||||
<span class="about-info-label about-info-label-block">Kompatible Browser</span>
|
||||
<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>
|
||||
@@ -272,8 +293,8 @@
|
||||
<div class="modal-overlay" id="themeOverlay">
|
||||
<div class="theme-modal" id="themeModal">
|
||||
<div class="modal-header">
|
||||
<span>Darstellung</span>
|
||||
<button class="btn-close" id="btnCloseTheme">✕</button>
|
||||
<span data-i18n="modal.theme_header">Darstellung</span>
|
||||
<button class="btn-close" id="btnCloseTheme" data-i18n-title="dialog.close">✕</button>
|
||||
</div>
|
||||
<div class="theme-grid">
|
||||
<div class="theme-card active" data-value="nebula">
|
||||
@@ -333,75 +354,75 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-modal-section">
|
||||
<h3 class="settings-section-title">HINTERGRUND</h3>
|
||||
<h3 class="settings-section-title" data-i18n="settings.section.bg">HINTERGRUND</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Bild-URL</span>
|
||||
<span class="setting-desc">Eigenes Hintergrundbild per URL</span>
|
||||
<span class="setting-label" data-i18n="settings.bg_url">Bild-URL</span>
|
||||
<span class="setting-desc" data-i18n="settings.bg_url.desc">Eigenes Hintergrundbild per URL</span>
|
||||
</div>
|
||||
<button class="btn-small" id="btnChangeBg">Ändern</button>
|
||||
<button class="btn-small" id="btnChangeBg" data-i18n="settings.bg_change">Ändern</button>
|
||||
</div>
|
||||
<div class="setting-row hidden" id="bgInputRow">
|
||||
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... oder leer für Standard" />
|
||||
<button class="btn-small" id="btnApplyBg">Übernehmen</button>
|
||||
<button class="btn-small" id="btnApplyBg" data-i18n="settings.bg_apply">Übernehmen</button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Datei hochladen</span>
|
||||
<span class="setting-desc">Lokales Bild als Hintergrund verwenden</span>
|
||||
<span class="setting-label" data-i18n="settings.bg_upload">Datei hochladen</span>
|
||||
<span class="setting-desc" data-i18n="settings.bg_upload.desc">Lokales Bild als Hintergrund verwenden</span>
|
||||
</div>
|
||||
<button class="btn-small" id="btnBgFile">Upload</button>
|
||||
<button class="btn-small" id="btnBgFile" data-i18n="settings.bg_upload_btn">Upload</button>
|
||||
<input type="file" id="bgFileInput" accept="image/*" class="hidden" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-modal-section">
|
||||
<h3 class="settings-section-title">DARSTELLUNG</h3>
|
||||
<h3 class="settings-section-title" data-i18n="settings.section.display">DARSTELLUNG</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Kompaktmodus</span>
|
||||
<span class="setting-desc">Weniger Abstand für mehr Bookmarks</span>
|
||||
<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">Lange Titel kürzen</span>
|
||||
<span class="setting-desc">Titel auf eine Zeile mit „…" kürzen</span>
|
||||
<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">Suchleiste anzeigen</span>
|
||||
<span class="setting-desc">Suchleiste unter dem Header ein/aus</span>
|
||||
<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">Links in neuem Tab</span>
|
||||
<span class="setting-desc">Bookmarks in neuem Browser-Tab öffnen</span>
|
||||
<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">Beschreibungen anzeigen</span>
|
||||
<span class="setting-desc">Gespeicherte Beschreibung unter Bookmarks</span>
|
||||
<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">Bookmarks ausblenden</span>
|
||||
<span class="setting-desc">Überzählige Bookmarks in langen Boards verstecken</span>
|
||||
<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">Sichtbare Bookmarks</span>
|
||||
<span class="setting-desc">Anzahl vor dem Ausblenden</span>
|
||||
<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>
|
||||
@@ -417,14 +438,14 @@
|
||||
<div class="modal-overlay" id="addBoardOverlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span>New Board</span>
|
||||
<button class="btn-close" id="btnCancelBoard">✕</button>
|
||||
<span data-i18n="modal.new_board">New Board</span>
|
||||
<button class="btn-close" id="btnCancelBoard" data-i18n-title="dialog.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" class="text-input full-width" id="newBoardName" placeholder="Board name..." maxlength="40" />
|
||||
<input type="text" class="text-input full-width" id="newBoardName" data-i18n-placeholder="modal.board_name" placeholder="Board name..." maxlength="40" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-primary" id="btnConfirmBoard">Create</button>
|
||||
<button class="btn-primary" id="btnConfirmBoard" data-i18n="modal.create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -433,16 +454,16 @@
|
||||
<div class="modal-overlay" id="addBookmarkOverlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span>New Bookmark</span>
|
||||
<button class="btn-close" id="btnCancelBookmark">✕</button>
|
||||
<span data-i18n="modal.new_bookmark">New Bookmark</span>
|
||||
<button class="btn-close" id="btnCancelBookmark" data-i18n-title="dialog.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" class="text-input full-width" id="newBmTitle" placeholder="Title..." maxlength="60" />
|
||||
<input type="text" class="text-input full-width" id="newBmTitle" data-i18n-placeholder="modal.bm_title" placeholder="Title..." maxlength="60" />
|
||||
<input type="url" class="text-input full-width modal-input-spaced" id="newBmUrl" placeholder="https://..." />
|
||||
<input type="text" class="text-input full-width modal-input-spaced" id="newBmDesc" placeholder="Description (optional)" />
|
||||
<input type="text" class="text-input full-width modal-input-spaced" id="newBmDesc" data-i18n-placeholder="modal.bm_desc" placeholder="Description (optional)" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-primary" id="btnConfirmBookmark">Add</button>
|
||||
<button class="btn-primary" id="btnConfirmBookmark" data-i18n="modal.bm_add">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -451,14 +472,14 @@
|
||||
<div class="modal-overlay" id="renameOverlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span>Rename</span>
|
||||
<button class="btn-close" id="btnCancelRename">✕</button>
|
||||
<span data-i18n="modal.rename">Rename</span>
|
||||
<button class="btn-close" id="btnCancelRename" data-i18n-title="dialog.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" class="text-input full-width" id="renameInput" placeholder="New name..." maxlength="60" />
|
||||
<input type="text" class="text-input full-width" id="renameInput" data-i18n-placeholder="modal.rename_placeholder" placeholder="New name..." maxlength="60" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-primary" id="btnConfirmRename">Rename</button>
|
||||
<button class="btn-primary" id="btnConfirmRename" data-i18n="modal.rename_confirm">Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,6 +493,8 @@
|
||||
<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 -->
|
||||
@@ -484,6 +507,11 @@
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":dependencyDashboard",
|
||||
":semanticCommits",
|
||||
":timezone(Europe/Berlin)",
|
||||
"schedule:weekly"
|
||||
],
|
||||
"labels": ["dependencies", "renovate"],
|
||||
"assignees": ["JonKazama-Hellion"],
|
||||
"prHourlyLimit": 10,
|
||||
"prConcurrentLimit": 20,
|
||||
"rebaseWhen": "behind-base-branch",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group all minor and patch updates per ecosystem in one PR",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "minor and patch updates ({{manager}})"
|
||||
},
|
||||
{
|
||||
"description": "Major updates always get their own PR with breaking-change label",
|
||||
"matchUpdateTypes": ["major"],
|
||||
"labels": ["dependencies", "major-update", "breaking-change"],
|
||||
"addLabels": ["needs-review"]
|
||||
},
|
||||
{
|
||||
"description": "TypeScript type definitions stay grouped with each other",
|
||||
"groupName": "type definitions",
|
||||
"matchPackageNames": [
|
||||
"@types/{/,}**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Dev dependencies in their own group",
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"groupName": "dev dependencies"
|
||||
},
|
||||
{
|
||||
"description": "Pin GitHub Action versions by SHA for supply-chain hygiene",
|
||||
"matchManagers": ["github-actions"],
|
||||
"pinDigests": true
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
"labels": ["security", "vulnerability"],
|
||||
"schedule": ["at any time"],
|
||||
"prPriority": 10
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": ["before 6am on monday"],
|
||||
"commitMessageAction": "Refresh"
|
||||
},
|
||||
"osvVulnerabilityAlerts": true
|
||||
}
|
||||
+323
-5
@@ -68,6 +68,19 @@
|
||||
--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));
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -91,6 +104,7 @@
|
||||
--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); }
|
||||
@@ -116,6 +130,7 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
@@ -146,6 +161,7 @@
|
||||
--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); }
|
||||
@@ -172,6 +188,7 @@
|
||||
--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); }
|
||||
@@ -197,6 +214,7 @@
|
||||
--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; }
|
||||
@@ -225,6 +243,7 @@
|
||||
--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);
|
||||
@@ -253,6 +272,7 @@
|
||||
--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 {
|
||||
@@ -287,6 +307,7 @@
|
||||
--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);
|
||||
@@ -322,6 +343,7 @@
|
||||
--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); }
|
||||
@@ -352,6 +374,7 @@
|
||||
--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); }
|
||||
@@ -382,6 +405,7 @@
|
||||
--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); }
|
||||
@@ -562,12 +586,13 @@ html, body {
|
||||
|
||||
body.compact .bm-item { padding: var(--spacing-compact) 10px; }
|
||||
|
||||
.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;
|
||||
.bm-favicon-local {
|
||||
width: 16px; height: 16px; flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 8px; color: var(--accent);
|
||||
font-size: 9px; font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
.bm-text { flex: 1; min-width: 0; }
|
||||
.bm-title { font-size: 12px; font-weight: 400; color: var(--text-primary); line-height: 1.3; }
|
||||
@@ -1257,6 +1282,299 @@ 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
|
||||
============================================ */
|
||||
|
||||
+12
-11
@@ -10,6 +10,7 @@ async function init() {
|
||||
boards = savedBoards ?? getDefaultBoards();
|
||||
if (savedSettings) Object.assign(settings, savedSettings);
|
||||
|
||||
I18n.init();
|
||||
applySettings();
|
||||
renderBoards();
|
||||
startClock();
|
||||
@@ -94,8 +95,8 @@ async function checkBackupReminder() {
|
||||
if (boards.length === 0) return;
|
||||
|
||||
const doBackup = await HellionDialog.confirm(
|
||||
'Du hast seit über einer Woche kein Backup gemacht. Beim Löschen der Browserdaten gehen deine Boards verloren. Jetzt sichern?',
|
||||
{ type: 'warning', title: 'Backup-Erinnerung', confirmText: 'Jetzt sichern', cancelText: 'Später' }
|
||||
t('app.backup_reminder'),
|
||||
{ type: 'warning', title: t('app.backup_reminder.title'), confirmText: t('app.backup_now'), cancelText: t('app.backup_later') }
|
||||
);
|
||||
|
||||
if (doBackup) {
|
||||
@@ -104,7 +105,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: '1.11.1', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets };
|
||||
const data = { version: '2.1.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');
|
||||
@@ -120,18 +121,18 @@ async function checkBackupReminder() {
|
||||
|
||||
// ---- CLOCK & DATE ----
|
||||
function startClock() {
|
||||
const DAYS = ['So','Mo','Di','Mi','Do','Fr','Sa'];
|
||||
const MONTHS = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
|
||||
const DAY_KEYS = ['clock.days.sun','clock.days.mon','clock.days.tue','clock.days.wed','clock.days.thu','clock.days.fri','clock.days.sat'];
|
||||
const MONTH_KEYS = ['clock.months.jan','clock.months.feb','clock.months.mar','clock.months.apr','clock.months.may','clock.months.jun','clock.months.jul','clock.months.aug','clock.months.sep','clock.months.oct','clock.months.nov','clock.months.dec'];
|
||||
|
||||
function tick() {
|
||||
const now = new Date();
|
||||
document.getElementById('clock').textContent =
|
||||
`${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||||
document.getElementById('date').textContent =
|
||||
`${DAYS[now.getDay()]}, ${String(now.getDate()).padStart(2,'0')}. ${MONTHS[now.getMonth()]}`;
|
||||
`${t(DAY_KEYS[now.getDay()])}, ${String(now.getDate()).padStart(2,'0')}. ${t(MONTH_KEYS[now.getMonth()])}`;
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
const clockInterval = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
// ---- GLOBALE EVENTS (Header-Buttons, Modals, Import) ----
|
||||
@@ -148,7 +149,7 @@ function bindGlobalEvents() {
|
||||
if (!file) return;
|
||||
const imported = parseBookmarkHtml(await file.text());
|
||||
if (imported.length === 0) {
|
||||
await HellionDialog.alert('Keine Bookmarks in dieser Datei gefunden.', { type: 'warning', title: 'Import' });
|
||||
await HellionDialog.alert(t('app.no_bookmarks'), { type: 'warning', title: t('app.import_title') });
|
||||
return;
|
||||
}
|
||||
boards = [...boards, ...imported];
|
||||
@@ -156,8 +157,8 @@ function bindGlobalEvents() {
|
||||
renderBoards();
|
||||
e.target.value = '';
|
||||
await HellionDialog.alert(
|
||||
`${imported.length} Board(s) mit ${imported.reduce((s,b) => s + b.bookmarks.length, 0)} Bookmarks importiert.`,
|
||||
{ type: 'success', title: 'Import erfolgreich' }
|
||||
t('app.html_import_success', { count: imported.length, total: imported.reduce((s,b) => s + b.bookmarks.length, 0) }),
|
||||
{ type: 'success', title: t('app.import_success_title') }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -189,7 +190,7 @@ function bindGlobalEvents() {
|
||||
const url = document.getElementById('newBmUrl').value.trim();
|
||||
const desc = document.getElementById('newBmDesc').value.trim();
|
||||
if (!title || !url) return;
|
||||
try { new URL(url); } catch { await HellionDialog.alert('Ungültige URL. Bitte mit https:// beginnen.', { type: 'warning', title: 'URL ungültig' }); return; }
|
||||
try { new URL(url); } catch { await HellionDialog.alert(t('app.invalid_url'), { type: 'warning', title: t('app.invalid_url.title') }); return; }
|
||||
const board = boards.find(b => b.id === pendingBookmarkBoardId);
|
||||
if (!board) return;
|
||||
board.bookmarks.push({ id: uid(), title, url, desc });
|
||||
|
||||
+21
-30
@@ -57,14 +57,14 @@ function renderBoards() {
|
||||
|
||||
const boardStrong = document.createElement('strong');
|
||||
boardStrong.className = 'accent-text';
|
||||
boardStrong.textContent = '+ Board';
|
||||
boardStrong.textContent = t('boards.add_board');
|
||||
|
||||
const importStrong = document.createElement('strong');
|
||||
importStrong.className = 'accent-text';
|
||||
importStrong.textContent = 'Import';
|
||||
importStrong.textContent = t('boards.import');
|
||||
|
||||
empty.append(
|
||||
'No boards yet. Click ', boardStrong, ' to create one, or use ', importStrong, ' to load your browser bookmarks.'
|
||||
t('boards.empty_state_pre'), boardStrong, t('boards.empty_state_mid'), importStrong, t('boards.empty_state_post')
|
||||
);
|
||||
wrapper.appendChild(empty);
|
||||
return;
|
||||
@@ -85,7 +85,7 @@ function createBoardEl(board) {
|
||||
|
||||
const dragHandle = document.createElement('span');
|
||||
dragHandle.className = 'board-drag-handle';
|
||||
dragHandle.title = 'Board verschieben';
|
||||
dragHandle.title = t('boards.drag_title');
|
||||
dragHandle.appendChild(createDragHandleSvg());
|
||||
|
||||
const titleSpanHeader = document.createElement('span');
|
||||
@@ -98,17 +98,17 @@ function createBoardEl(board) {
|
||||
|
||||
const btnBlur = document.createElement('button');
|
||||
btnBlur.className = 'board-action-btn btn-blur-board';
|
||||
btnBlur.title = board.blurred ? 'Unblur' : 'Blur (privat)';
|
||||
btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur');
|
||||
btnBlur.textContent = '\uD83D\uDD12';
|
||||
|
||||
const btnRename = document.createElement('button');
|
||||
btnRename.className = 'board-action-btn btn-rename-board';
|
||||
btnRename.title = 'Umbenennen';
|
||||
btnRename.title = t('boards.rename');
|
||||
btnRename.textContent = '\u270E';
|
||||
|
||||
const btnDelete = document.createElement('button');
|
||||
btnDelete.className = 'board-action-btn btn-delete-board';
|
||||
btnDelete.title = 'Löschen';
|
||||
btnDelete.title = t('boards.delete');
|
||||
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 ? 'Unblur' : 'Blur (privat)';
|
||||
btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur');
|
||||
await saveBoards();
|
||||
});
|
||||
|
||||
blurOverlay.addEventListener('click', async () => {
|
||||
board.blurred = false;
|
||||
div.classList.remove('blurred');
|
||||
btnBlur.title = 'Blur (privat)';
|
||||
btnBlur.title = t('boards.blur');
|
||||
await saveBoards();
|
||||
});
|
||||
|
||||
@@ -147,8 +147,8 @@ function createBoardEl(board) {
|
||||
btnDelete.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const ok = await HellionDialog.confirm(
|
||||
`Board "${board.title}" wirklich löschen?`,
|
||||
{ type: 'danger', title: 'Board löschen', confirmText: 'Löschen' }
|
||||
t('boards.delete_confirm', { title: board.title }),
|
||||
{ type: 'danger', title: t('boards.delete_confirm.title'), confirmText: t('boards.delete') }
|
||||
);
|
||||
if (ok) {
|
||||
boards = boards.filter(b => b.id !== board.id);
|
||||
@@ -180,16 +180,16 @@ function createBoardEl(board) {
|
||||
let hiddenEls = [];
|
||||
const showMoreBtn = document.createElement('button');
|
||||
showMoreBtn.className = 'show-more-btn';
|
||||
showMoreBtn.textContent = `Show ${hidden.length} more…`;
|
||||
showMoreBtn.textContent = t('boards.show_more', { count: hidden.length });
|
||||
showMoreBtn.addEventListener('click', () => {
|
||||
if (!expanded) {
|
||||
hidden.forEach(bm => { const el = createBmEl(bm); hiddenEls.push(el); list.appendChild(el); });
|
||||
showMoreBtn.textContent = 'Show less';
|
||||
showMoreBtn.textContent = t('boards.show_less');
|
||||
expanded = true;
|
||||
} else {
|
||||
hiddenEls.forEach(el => el.remove());
|
||||
hiddenEls = [];
|
||||
showMoreBtn.textContent = `Show ${hidden.length} more…`;
|
||||
showMoreBtn.textContent = t('boards.show_more', { count: hidden.length });
|
||||
expanded = false;
|
||||
}
|
||||
});
|
||||
@@ -200,7 +200,7 @@ function createBoardEl(board) {
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'add-bm-btn';
|
||||
addBtn.appendChild(createPlusSvg());
|
||||
addBtn.append(' Add link');
|
||||
addBtn.append(t('boards.add_link'));
|
||||
addBtn.addEventListener('click', () => openAddBookmarkModal(board.id));
|
||||
div.appendChild(addBtn);
|
||||
|
||||
@@ -215,19 +215,11 @@ function createBmEl(bm) {
|
||||
li.dataset.bmUrl = bm.url;
|
||||
li.draggable = true;
|
||||
|
||||
const favicon = document.createElement('img');
|
||||
favicon.className = 'bm-favicon';
|
||||
favicon.width = 14;
|
||||
favicon.height = 14;
|
||||
favicon.src = getFaviconUrl(bm.url);
|
||||
favicon.addEventListener('error', function() {
|
||||
this.classList.add('hidden');
|
||||
this.nextElementSibling.classList.remove('hidden');
|
||||
});
|
||||
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'bm-favicon-fallback hidden';
|
||||
fallback.textContent = bm.title.charAt(0).toUpperCase();
|
||||
const favicon = document.createElement('div');
|
||||
favicon.className = 'bm-favicon-local';
|
||||
favicon.textContent = bm.title.charAt(0).toUpperCase();
|
||||
const hue = (bm.title.charCodeAt(0) * 137) % 360;
|
||||
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
|
||||
|
||||
const textDiv = document.createElement('div');
|
||||
textDiv.className = 'bm-text';
|
||||
@@ -243,11 +235,10 @@ function createBmEl(bm) {
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'bm-delete';
|
||||
deleteBtn.title = 'Entfernen';
|
||||
deleteBtn.title = t('boards.remove_bookmark');
|
||||
deleteBtn.textContent = '✕';
|
||||
|
||||
li.appendChild(favicon);
|
||||
li.appendChild(fallback);
|
||||
li.appendChild(textDiv);
|
||||
li.appendChild(deleteBtn);
|
||||
|
||||
|
||||
+19
-19
@@ -42,8 +42,8 @@ const BrowserBookmarkImport = {
|
||||
tree = await api.getTree();
|
||||
} catch (err) {
|
||||
await HellionDialog.alert(
|
||||
'Zugriff auf Browser-Lesezeichen nicht möglich. Stelle sicher, dass die Extension die nötigen Berechtigungen hat.',
|
||||
{ type: 'warning', title: 'Lesezeichen-Import' }
|
||||
t('bm_import.no_access'),
|
||||
{ type: 'warning', title: t('bm_import.title') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -51,8 +51,8 @@ const BrowserBookmarkImport = {
|
||||
const folders = this._extractFolders(tree[0]);
|
||||
if (folders.length === 0) {
|
||||
await HellionDialog.alert(
|
||||
'Keine Lesezeichen-Ordner gefunden.',
|
||||
{ type: 'warning', title: 'Lesezeichen-Import' }
|
||||
t('bm_import.no_folders'),
|
||||
{ type: 'warning', title: t('bm_import.title') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -79,7 +79,7 @@ const BrowserBookmarkImport = {
|
||||
|
||||
result.push({
|
||||
id: child.id,
|
||||
title: child.title || 'Unbenannt',
|
||||
title: child.title || t('bm_import.unnamed'),
|
||||
depth: depth,
|
||||
bookmarkCount: bookmarkCount,
|
||||
subfolderCount: subfolderCount,
|
||||
@@ -114,7 +114,7 @@ const BrowserBookmarkImport = {
|
||||
header.className = 'bm-import-header';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.textContent = 'Browser-Lesezeichen importieren';
|
||||
title.textContent = t('bm_import.modal_title');
|
||||
header.appendChild(title);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
@@ -128,7 +128,7 @@ const BrowserBookmarkImport = {
|
||||
// Info
|
||||
const info = document.createElement('div');
|
||||
info.className = 'bm-import-info';
|
||||
info.textContent = 'Wähle die Ordner aus, die als Boards importiert werden sollen. Jeder Ordner wird ein eigenes Board.';
|
||||
info.textContent = t('bm_import.info');
|
||||
modal.appendChild(info);
|
||||
|
||||
// Ordner-Liste
|
||||
@@ -155,13 +155,13 @@ const BrowserBookmarkImport = {
|
||||
meta.className = 'bm-import-folder-meta';
|
||||
const parts = [];
|
||||
if (folder.bookmarkCount > 0) {
|
||||
parts.push(folder.bookmarkCount + ' Link' + (folder.bookmarkCount !== 1 ? 's' : ''));
|
||||
parts.push(t('bm_import.link_count', { count: folder.bookmarkCount }));
|
||||
}
|
||||
if (folder.subfolderCount > 0) {
|
||||
parts.push(folder.subfolderCount + ' Ordner');
|
||||
parts.push(t('bm_import.folder_count', { count: folder.subfolderCount }));
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
parts.push('leer');
|
||||
parts.push(t('bm_import.empty'));
|
||||
}
|
||||
meta.textContent = parts.join(', ');
|
||||
row.appendChild(meta);
|
||||
@@ -177,18 +177,18 @@ const BrowserBookmarkImport = {
|
||||
|
||||
const selectAll = document.createElement('button');
|
||||
selectAll.className = 'btn-secondary';
|
||||
selectAll.textContent = 'Alle auswählen';
|
||||
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 ? 'Alle auswählen' : 'Alle abwählen';
|
||||
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 = 'Importieren';
|
||||
importBtn.textContent = t('bm_import.import_btn');
|
||||
importBtn.addEventListener('click', () => this._importSelected(folders));
|
||||
footer.appendChild(importBtn);
|
||||
|
||||
@@ -216,8 +216,8 @@ const BrowserBookmarkImport = {
|
||||
const checkboxes = document.querySelectorAll('.bm-import-checkbox:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
await HellionDialog.alert(
|
||||
'Bitte wähle mindestens einen Ordner aus.',
|
||||
{ type: 'warning', title: 'Lesezeichen-Import' }
|
||||
t('bm_import.no_selection'),
|
||||
{ type: 'warning', title: t('bm_import.title') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -289,15 +289,15 @@ const BrowserBookmarkImport = {
|
||||
|
||||
// Ergebnis-Dialog
|
||||
const lines = [];
|
||||
lines.push(boardsCreated + ' Board' + (boardsCreated !== 1 ? 's' : '') + ' erstellt');
|
||||
lines.push(totalImported + ' Lesezeichen importiert');
|
||||
lines.push(t('bm_import.boards_created', { count: boardsCreated }));
|
||||
lines.push(t('bm_import.bookmarks_imported', { count: totalImported }));
|
||||
if (totalSkipped > 0) {
|
||||
lines.push(totalSkipped + ' Duplikat' + (totalSkipped !== 1 ? 'e' : '') + ' übersprungen');
|
||||
lines.push(t('bm_import.duplicates_skipped', { count: totalSkipped }));
|
||||
}
|
||||
|
||||
await HellionDialog.alert(
|
||||
lines.join('\n'),
|
||||
{ type: 'success', title: 'Import abgeschlossen' }
|
||||
{ type: 'success', title: t('bm_import.success_title') }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — calc-converter.js
|
||||
Unit-Converter Modus für Calculator Widget
|
||||
============================================= */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const CATEGORIES = {
|
||||
length: {
|
||||
titleKey: 'calculator.conv.cat.length',
|
||||
baseUnit: 'm',
|
||||
units: {
|
||||
mm: { toBase: v => v / 1000, fromBase: v => v * 1000 },
|
||||
cm: { toBase: v => v / 100, fromBase: v => v * 100 },
|
||||
m: { toBase: v => v, fromBase: v => v },
|
||||
km: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||
in: { toBase: v => v * 0.0254, fromBase: v => v / 0.0254 },
|
||||
ft: { toBase: v => v * 0.3048, fromBase: v => v / 0.3048 },
|
||||
yd: { toBase: v => v * 0.9144, fromBase: v => v / 0.9144 },
|
||||
mi: { toBase: v => v * 1609.344, fromBase: v => v / 1609.344 }
|
||||
}
|
||||
},
|
||||
weight: {
|
||||
titleKey: 'calculator.conv.cat.weight',
|
||||
baseUnit: 'g',
|
||||
units: {
|
||||
mg: { toBase: v => v / 1000, fromBase: v => v * 1000 },
|
||||
g: { toBase: v => v, fromBase: v => v },
|
||||
kg: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||
t: { toBase: v => v * 1000000, fromBase: v => v / 1000000 },
|
||||
oz: { toBase: v => v * 28.3495, fromBase: v => v / 28.3495 },
|
||||
lb: { toBase: v => v * 453.592, fromBase: v => v / 453.592 }
|
||||
}
|
||||
},
|
||||
temperature: {
|
||||
titleKey: 'calculator.conv.cat.temperature',
|
||||
baseUnit: null,
|
||||
units: { '\u00B0C': null, '\u00B0F': null, 'K': null },
|
||||
convert(value, from, to) {
|
||||
if (from === to) return value;
|
||||
const key = from + '_' + to;
|
||||
const conversions = {
|
||||
'\u00B0C_\u00B0F': v => (v * 9 / 5) + 32,
|
||||
'\u00B0C_K': v => v + 273.15,
|
||||
'\u00B0F_\u00B0C': v => (v - 32) * 5 / 9,
|
||||
'\u00B0F_K': v => (v - 32) * 5 / 9 + 273.15,
|
||||
'K_\u00B0C': v => v - 273.15,
|
||||
'K_\u00B0F': v => (v - 273.15) * 9 / 5 + 32
|
||||
};
|
||||
const fn = conversions[key];
|
||||
return fn ? fn(value) : null;
|
||||
}
|
||||
},
|
||||
volume: {
|
||||
titleKey: 'calculator.conv.cat.volume',
|
||||
baseUnit: 'ml',
|
||||
units: {
|
||||
ml: { toBase: v => v, fromBase: v => v },
|
||||
L: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||
'm\u00B3':{ toBase: v => v * 1000000, fromBase: v => v / 1000000 },
|
||||
'gal(US)':{ toBase: v => v * 3785.41, fromBase: v => v / 3785.41 },
|
||||
'gal(UK)':{ toBase: v => v * 4546.09, fromBase: v => v / 4546.09 },
|
||||
'ft\u00B3':{ toBase: v => v * 28316.8, fromBase: v => v / 28316.8 }
|
||||
}
|
||||
},
|
||||
speed: {
|
||||
titleKey: 'calculator.conv.cat.speed',
|
||||
baseUnit: 'm/s',
|
||||
units: {
|
||||
'm/s': { toBase: v => v, fromBase: v => v },
|
||||
'km/h': { toBase: v => v / 3.6, fromBase: v => v * 3.6 },
|
||||
'mph': { toBase: v => v * 0.44704, fromBase: v => v / 0.44704 },
|
||||
'kn': { toBase: v => v * 0.514444, fromBase: v => v / 0.514444 }
|
||||
}
|
||||
},
|
||||
area: {
|
||||
titleKey: 'calculator.conv.cat.area',
|
||||
baseUnit: 'm\u00B2',
|
||||
units: {
|
||||
'mm\u00B2': { toBase: v => v / 1000000, fromBase: v => v * 1000000 },
|
||||
'cm\u00B2': { toBase: v => v / 10000, fromBase: v => v * 10000 },
|
||||
'm\u00B2': { toBase: v => v, fromBase: v => v },
|
||||
'km\u00B2': { toBase: v => v * 1000000, fromBase: v => v / 1000000 },
|
||||
'ha': { toBase: v => v * 10000, fromBase: v => v / 10000 },
|
||||
'acre': { toBase: v => v * 4046.86, fromBase: v => v / 4046.86 },
|
||||
'ft\u00B2': { toBase: v => v * 0.092903, fromBase: v => v / 0.092903 },
|
||||
'in\u00B2': { toBase: v => v * 0.00064516, fromBase: v => v / 0.00064516 }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = ['length', 'weight', 'temperature', 'volume', 'speed', 'area'];
|
||||
|
||||
let _currentCategory = 'length';
|
||||
let _fromUnit = 'cm';
|
||||
let _toUnit = 'in';
|
||||
let _fromInput = null;
|
||||
let _toInput = null;
|
||||
let _refEl = null;
|
||||
|
||||
/**
|
||||
* Converts a value from one unit to another within the current category.
|
||||
* @param {number} value
|
||||
* @param {string} from
|
||||
* @param {string} to
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function convert(value, from, to) {
|
||||
const cat = CATEGORIES[_currentCategory];
|
||||
if (!cat) return null;
|
||||
if (cat.convert) return cat.convert(value, from, to);
|
||||
const fromDef = cat.units[from];
|
||||
const toDef = cat.units[to];
|
||||
if (!fromDef || !toDef) return null;
|
||||
const base = fromDef.toBase(value);
|
||||
return toDef.fromBase(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculates the output field and reference lines based on current input.
|
||||
*/
|
||||
function recalc() {
|
||||
if (!_fromInput || !_toInput) return;
|
||||
const val = parseFloat(_fromInput.value);
|
||||
if (isNaN(val)) {
|
||||
_toInput.value = '';
|
||||
updateReference();
|
||||
return;
|
||||
}
|
||||
const result = convert(val, _fromUnit, _toUnit);
|
||||
if (result === null) {
|
||||
_toInput.value = '';
|
||||
} else {
|
||||
_toInput.value = Calculator._formatResult(result);
|
||||
}
|
||||
updateReference();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the reference conversion lines below the inputs.
|
||||
*/
|
||||
function updateReference() {
|
||||
if (!_refEl) return;
|
||||
_refEl.textContent = '';
|
||||
const r1 = convert(1, _fromUnit, _toUnit);
|
||||
const r2 = convert(1, _toUnit, _fromUnit);
|
||||
if (r1 !== null) {
|
||||
const line1 = document.createElement('div');
|
||||
line1.textContent = '1 ' + _fromUnit + ' = ' + Calculator._formatResult(r1) + ' ' + _toUnit;
|
||||
_refEl.appendChild(line1);
|
||||
}
|
||||
if (r2 !== null) {
|
||||
const line2 = document.createElement('div');
|
||||
line2.textContent = '1 ' + _toUnit + ' = ' + Calculator._formatResult(r2) + ' ' + _fromUnit;
|
||||
_refEl.appendChild(line2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates a unit <select> element with options for the current category.
|
||||
* @param {HTMLSelectElement} selectEl
|
||||
* @param {string} selectedUnit
|
||||
*/
|
||||
function populateUnitSelect(selectEl, selectedUnit) {
|
||||
while (selectEl.firstChild) {
|
||||
selectEl.removeChild(selectEl.firstChild);
|
||||
}
|
||||
const cat = CATEGORIES[_currentCategory];
|
||||
if (!cat) return;
|
||||
const units = Object.keys(cat.units);
|
||||
units.forEach(unit => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = unit;
|
||||
opt.textContent = unit;
|
||||
if (unit === selectedUnit) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sensible default from/to units for a given category key.
|
||||
* @param {string} catKey
|
||||
* @returns {{ from: string, to: string }}
|
||||
*/
|
||||
function getDefaultUnits(catKey) {
|
||||
const defaults = {
|
||||
length: { from: 'cm', to: 'in' },
|
||||
weight: { from: 'kg', to: 'lb' },
|
||||
temperature: { from: '\u00B0C', to: '\u00B0F' },
|
||||
volume: { from: 'L', to: 'gal(US)' },
|
||||
speed: { from: 'km/h', to: 'mph' },
|
||||
area: { from: 'm\u00B2', to: 'ft\u00B2' }
|
||||
};
|
||||
return defaults[catKey] || { from: Object.keys(CATEGORIES[catKey].units)[0], to: Object.keys(CATEGORIES[catKey].units)[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads persisted converter state from storage.
|
||||
*/
|
||||
async function loadState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||
if (data && data.calculator && data.calculator.converter) {
|
||||
const s = data.calculator.converter;
|
||||
if (s.lastCategory && CATEGORIES[s.lastCategory]) _currentCategory = s.lastCategory;
|
||||
if (s.fromUnit) _fromUnit = s.fromUnit;
|
||||
if (s.toUnit) _toUnit = s.toUnit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists current converter state to storage (read-before-write).
|
||||
*/
|
||||
async function saveState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||
if (!data.calculator) data.calculator = {};
|
||||
data.calculator.converter = {
|
||||
lastCategory: _currentCategory,
|
||||
fromUnit: _fromUnit,
|
||||
toUnit: _toUnit
|
||||
};
|
||||
await Store.set(Calculator.STORAGE_KEY, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the converter UI and appends it to the widget body element.
|
||||
* @param {HTMLElement} bodyEl
|
||||
*/
|
||||
function buildUI(bodyEl) {
|
||||
const catSelect = document.createElement('select');
|
||||
catSelect.className = 'calc-conv-select';
|
||||
|
||||
CATEGORY_ORDER.forEach(catKey => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = catKey;
|
||||
opt.textContent = t(CATEGORIES[catKey].titleKey);
|
||||
if (catKey === _currentCategory) opt.selected = true;
|
||||
catSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
const fromRow = document.createElement('div');
|
||||
fromRow.className = 'calc-conv-row';
|
||||
|
||||
_fromInput = document.createElement('input');
|
||||
_fromInput.type = 'number';
|
||||
_fromInput.className = 'calc-conv-input';
|
||||
_fromInput.placeholder = '0';
|
||||
_fromInput.step = 'any';
|
||||
|
||||
const fromSelect = document.createElement('select');
|
||||
fromSelect.className = 'calc-conv-unit';
|
||||
populateUnitSelect(fromSelect, _fromUnit);
|
||||
|
||||
fromRow.append(_fromInput, fromSelect);
|
||||
|
||||
const swapBtn = document.createElement('button');
|
||||
swapBtn.type = 'button';
|
||||
swapBtn.className = 'calc-conv-swap';
|
||||
swapBtn.textContent = '\u21C5';
|
||||
swapBtn.title = t('calculator.conv.swap');
|
||||
|
||||
const toRow = document.createElement('div');
|
||||
toRow.className = 'calc-conv-row';
|
||||
|
||||
_toInput = document.createElement('input');
|
||||
_toInput.type = 'text';
|
||||
_toInput.className = 'calc-conv-input';
|
||||
_toInput.readOnly = true;
|
||||
_toInput.placeholder = '0';
|
||||
|
||||
const toSelect = document.createElement('select');
|
||||
toSelect.className = 'calc-conv-unit';
|
||||
populateUnitSelect(toSelect, _toUnit);
|
||||
|
||||
toRow.append(_toInput, toSelect);
|
||||
|
||||
_refEl = document.createElement('div');
|
||||
_refEl.className = 'calc-conv-ref';
|
||||
|
||||
_fromInput.addEventListener('input', () => recalc());
|
||||
fromSelect.addEventListener('change', () => {
|
||||
_fromUnit = fromSelect.value;
|
||||
recalc();
|
||||
saveState();
|
||||
});
|
||||
toSelect.addEventListener('change', () => {
|
||||
_toUnit = toSelect.value;
|
||||
recalc();
|
||||
saveState();
|
||||
});
|
||||
swapBtn.addEventListener('click', () => {
|
||||
const tmpUnit = _fromUnit;
|
||||
_fromUnit = _toUnit;
|
||||
_toUnit = tmpUnit;
|
||||
populateUnitSelect(fromSelect, _fromUnit);
|
||||
populateUnitSelect(toSelect, _toUnit);
|
||||
const currentVal = _toInput.value;
|
||||
if (currentVal) {
|
||||
_fromInput.value = currentVal;
|
||||
}
|
||||
recalc();
|
||||
saveState();
|
||||
});
|
||||
catSelect.addEventListener('change', () => {
|
||||
_currentCategory = catSelect.value;
|
||||
const defaults = getDefaultUnits(_currentCategory);
|
||||
_fromUnit = defaults.from;
|
||||
_toUnit = defaults.to;
|
||||
populateUnitSelect(fromSelect, _fromUnit);
|
||||
populateUnitSelect(toSelect, _toUnit);
|
||||
_fromInput.value = '';
|
||||
_toInput.value = '';
|
||||
updateReference();
|
||||
saveState();
|
||||
});
|
||||
|
||||
bodyEl.append(catSelect, fromRow, swapBtn, toRow, _refEl);
|
||||
updateReference();
|
||||
}
|
||||
|
||||
Calculator.registerMode('converter', {
|
||||
label: '⚖️',
|
||||
shortName: 'Unit',
|
||||
titleKey: 'calculator.tab.converter',
|
||||
|
||||
render(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.gap = '8px';
|
||||
|
||||
loadState().then(() => {
|
||||
buildUI(bodyEl);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
_fromInput = null;
|
||||
_toInput = null;
|
||||
_refEl = null;
|
||||
saveState();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,247 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — calc-factorio.js
|
||||
Factorio Calculator Modus
|
||||
============================================= */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const ASSEMBLERS = [
|
||||
{ key: 'asm1', speed: 0.5 },
|
||||
{ key: 'asm2', speed: 0.75 },
|
||||
{ key: 'asm3', speed: 1.25 }
|
||||
];
|
||||
|
||||
const BELTS = [
|
||||
{ key: 'yellow', throughput: 15, perSide: 7.5 },
|
||||
{ key: 'red', throughput: 30, perSide: 15 },
|
||||
{ key: 'blue', throughput: 45, perSide: 22.5 }
|
||||
];
|
||||
|
||||
const SUB_MODES = ['ratio', 'belt', 'machines'];
|
||||
let _activeSubMode = 'ratio';
|
||||
|
||||
function createAssemblerSelect(selectedKey) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = t('calculator.fac.assembler');
|
||||
const select = document.createElement('select');
|
||||
select.className = 'calc-game-input';
|
||||
ASSEMBLERS.forEach(asm => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = asm.key;
|
||||
opt.textContent = t('calculator.fac.asm.' + asm.key) + ' (' + asm.speed + 'x)';
|
||||
if (asm.key === selectedKey) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
row.append(label, select);
|
||||
return { row, select };
|
||||
}
|
||||
|
||||
function createBeltSelect(selectedKey) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = t('calculator.fac.belt');
|
||||
const select = document.createElement('select');
|
||||
select.className = 'calc-game-input';
|
||||
BELTS.forEach(belt => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = belt.key;
|
||||
opt.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + belt.throughput + '/s)';
|
||||
if (belt.key === selectedKey) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
row.append(label, select);
|
||||
return { row, select };
|
||||
}
|
||||
|
||||
function getAssemblerSpeed(key) {
|
||||
const asm = ASSEMBLERS.find(a => a.key === key);
|
||||
return asm ? asm.speed : 1;
|
||||
}
|
||||
|
||||
function getBelt(key) {
|
||||
return BELTS.find(b => b.key === key) || BELTS[0];
|
||||
}
|
||||
|
||||
function findSmallestBelt(throughput) {
|
||||
for (const belt of BELTS) {
|
||||
if (belt.throughput >= throughput) return belt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createField(labelKey, defaultVal, opts) {
|
||||
opts = opts || {};
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = t(labelKey);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'calc-game-input';
|
||||
input.value = defaultVal;
|
||||
if (opts.step) input.step = opts.step;
|
||||
if (opts.min !== undefined) input.min = opts.min;
|
||||
row.append(label, input);
|
||||
return { row, input };
|
||||
}
|
||||
|
||||
function createOutput(labelKey) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-output';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = t(labelKey);
|
||||
const value = document.createElement('span');
|
||||
value.className = 'calc-game-value';
|
||||
row.append(label, value);
|
||||
return { row, value };
|
||||
}
|
||||
|
||||
function renderRatio(container) {
|
||||
const asmSelect = createAssemblerSelect('asm3');
|
||||
const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
|
||||
const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });
|
||||
const perSecOutput = createOutput('calculator.fac.items_per_sec');
|
||||
const perMinOutput = createOutput('calculator.fac.items_per_min');
|
||||
|
||||
function calc() {
|
||||
const speed = getAssemblerSpeed(asmSelect.select.value);
|
||||
const output = parseFloat(outputField.input.value) || 0;
|
||||
const time = parseFloat(timeField.input.value) || 1;
|
||||
const perSec = output * speed / time;
|
||||
const perMin = perSec * 60;
|
||||
perSecOutput.value.textContent = Calculator._formatResult(perSec) + ' /s';
|
||||
perMinOutput.value.textContent = Calculator._formatResult(perMin) + ' /min';
|
||||
}
|
||||
|
||||
[outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
|
||||
asmSelect.select.addEventListener('change', calc);
|
||||
container.append(asmSelect.row, outputField.row, timeField.row, perSecOutput.row, perMinOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderBelt(container) {
|
||||
const beltSelect = createBeltSelect('yellow');
|
||||
const consumeField = createField('calculator.fac.consume_per_sec', 1, { step: 0.1, min: 0.1 });
|
||||
const machinesOutput = createOutput('calculator.fac.machines_per_belt');
|
||||
const utilOutput = createOutput('calculator.fac.belt_utilization');
|
||||
|
||||
function calc() {
|
||||
const belt = getBelt(beltSelect.select.value);
|
||||
const consume = parseFloat(consumeField.input.value) || 1;
|
||||
const machines = Math.floor(belt.throughput / consume);
|
||||
const util = (consume * machines) / belt.throughput * 100;
|
||||
machinesOutput.value.textContent = machines;
|
||||
utilOutput.value.textContent = Calculator._formatResult(util) + '%';
|
||||
}
|
||||
|
||||
consumeField.input.addEventListener('input', calc);
|
||||
beltSelect.select.addEventListener('change', calc);
|
||||
container.append(beltSelect.row, consumeField.row, machinesOutput.row, utilOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderMachines(container) {
|
||||
const asmSelect = createAssemblerSelect('asm3');
|
||||
const targetField = createField('calculator.fac.target_output_sec', 10, { step: 0.1, min: 0.1 });
|
||||
const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
|
||||
const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });
|
||||
const machinesOutput = createOutput('calculator.fac.machines_needed');
|
||||
const beltOutput = createOutput('calculator.fac.belt_needed');
|
||||
|
||||
function calc() {
|
||||
const speed = getAssemblerSpeed(asmSelect.select.value);
|
||||
const target = parseFloat(targetField.input.value) || 0;
|
||||
const output = parseFloat(outputField.input.value) || 1;
|
||||
const time = parseFloat(timeField.input.value) || 1;
|
||||
const perMachine = output * speed / time;
|
||||
const machines = perMachine > 0 ? Math.ceil(target / perMachine) : 0;
|
||||
const totalThroughput = machines * perMachine;
|
||||
const belt = findSmallestBelt(totalThroughput);
|
||||
|
||||
machinesOutput.value.textContent = machines;
|
||||
if (belt) {
|
||||
const util = (totalThroughput / belt.throughput) * 100;
|
||||
beltOutput.value.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + Calculator._formatResult(util) + '%)';
|
||||
} else {
|
||||
beltOutput.value.textContent = t('calculator.fac.exceeds_belt');
|
||||
}
|
||||
}
|
||||
|
||||
[targetField, outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
|
||||
asmSelect.select.addEventListener('change', calc);
|
||||
container.append(asmSelect.row, targetField.row, outputField.row, timeField.row, machinesOutput.row, beltOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||
if (data && data.calculator && data.calculator.factorio) {
|
||||
const s = data.calculator.factorio;
|
||||
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||
if (!data.calculator) data.calculator = {};
|
||||
data.calculator.factorio = { lastSubMode: _activeSubMode };
|
||||
await Store.set(Calculator.STORAGE_KEY, data);
|
||||
}
|
||||
|
||||
function renderSubMode(container) {
|
||||
container.textContent = '';
|
||||
switch (_activeSubMode) {
|
||||
case 'ratio': renderRatio(container); break;
|
||||
case 'belt': renderBelt(container); break;
|
||||
case 'machines': renderMachines(container); break;
|
||||
}
|
||||
}
|
||||
|
||||
Calculator.registerMode('factorio', {
|
||||
label: '🏭',
|
||||
shortName: 'FAC',
|
||||
titleKey: 'calculator.tab.factorio',
|
||||
|
||||
render(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.gap = '8px';
|
||||
|
||||
loadState().then(() => {
|
||||
const subContent = document.createElement('div');
|
||||
subContent.className = 'calc-game-content';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'calc-game-subtabs';
|
||||
|
||||
SUB_MODES.forEach(mode => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
|
||||
btn.textContent = t('calculator.fac.tab.' + mode);
|
||||
btn.dataset.mode = mode;
|
||||
btn.addEventListener('click', () => {
|
||||
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_activeSubMode = mode;
|
||||
renderSubMode(subContent);
|
||||
saveState();
|
||||
});
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
|
||||
bodyEl.append(bar, subContent);
|
||||
renderSubMode(subContent);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
saveState();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,184 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — calc-satisfactory.js
|
||||
Satisfactory Calculator Modus
|
||||
============================================= */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const POWER_EXPONENT = 1.321928;
|
||||
const SUB_MODES = ['itemsPerMin', 'power', 'machines'];
|
||||
let _activeSubMode = 'itemsPerMin';
|
||||
|
||||
function createField(labelKey, defaultVal, opts) {
|
||||
opts = opts || {};
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = t(labelKey);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'calc-game-input';
|
||||
input.value = defaultVal;
|
||||
if (opts.step) input.step = opts.step;
|
||||
if (opts.min !== undefined) input.min = opts.min;
|
||||
if (opts.max !== undefined) input.max = opts.max;
|
||||
row.append(label, input);
|
||||
return { row, input };
|
||||
}
|
||||
|
||||
function createOutput(labelKey) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-output';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = t(labelKey);
|
||||
const value = document.createElement('span');
|
||||
value.className = 'calc-game-value';
|
||||
row.append(label, value);
|
||||
return { row, value };
|
||||
}
|
||||
|
||||
function renderItemsPerMin(container) {
|
||||
const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
|
||||
const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
|
||||
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
|
||||
const output = createOutput('calculator.sat.output_per_min');
|
||||
|
||||
function calc() {
|
||||
const items = parseFloat(itemsField.input.value) || 0;
|
||||
const time = parseFloat(timeField.input.value) || 1;
|
||||
const clock = parseFloat(clockField.input.value) || 100;
|
||||
const result = (items * 60) / time * (clock / 100);
|
||||
output.value.textContent = Calculator._formatResult(result) + ' items/min';
|
||||
}
|
||||
|
||||
[itemsField, timeField, clockField].forEach(f => f.input.addEventListener('input', calc));
|
||||
container.append(itemsField.row, timeField.row, clockField.row, output.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderPower(container) {
|
||||
const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });
|
||||
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
|
||||
const powerOutput = createOutput('calculator.sat.power_usage');
|
||||
const effOutput = createOutput('calculator.sat.efficiency');
|
||||
|
||||
function calc() {
|
||||
const basePower = parseFloat(basePowerField.input.value) || 0;
|
||||
const clock = parseFloat(clockField.input.value) || 100;
|
||||
const ratio = clock / 100;
|
||||
const power = basePower * Math.pow(ratio, POWER_EXPONENT);
|
||||
const effPerItem = Math.pow(ratio, POWER_EXPONENT - 1);
|
||||
|
||||
powerOutput.value.textContent = Calculator._formatResult(power) + ' MW';
|
||||
|
||||
if (clock > 100) {
|
||||
const overhead = (effPerItem - 1) * 100;
|
||||
effOutput.value.textContent = '+' + Calculator._formatResult(overhead) + '% ' + t('calculator.sat.per_item');
|
||||
effOutput.row.style.display = '';
|
||||
} else {
|
||||
effOutput.row.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
[basePowerField, clockField].forEach(f => f.input.addEventListener('input', calc));
|
||||
container.append(basePowerField.row, clockField.row, powerOutput.row, effOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderMachines(container) {
|
||||
const targetField = createField('calculator.sat.target_output', 60, { step: 1, min: 1 });
|
||||
const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
|
||||
const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
|
||||
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
|
||||
const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });
|
||||
const machinesOutput = createOutput('calculator.sat.machines_needed');
|
||||
const totalPowerOutput = createOutput('calculator.sat.total_power');
|
||||
|
||||
function calc() {
|
||||
const target = parseFloat(targetField.input.value) || 0;
|
||||
const items = parseFloat(itemsField.input.value) || 1;
|
||||
const time = parseFloat(timeField.input.value) || 1;
|
||||
const clock = parseFloat(clockField.input.value) || 100;
|
||||
const basePower = parseFloat(basePowerField.input.value) || 0;
|
||||
const ratio = clock / 100;
|
||||
const itemsPerMin = (items * 60) / time * ratio;
|
||||
const machines = itemsPerMin > 0 ? Math.ceil(target / itemsPerMin) : 0;
|
||||
const totalPower = machines * basePower * Math.pow(ratio, POWER_EXPONENT);
|
||||
machinesOutput.value.textContent = machines;
|
||||
totalPowerOutput.value.textContent = Calculator._formatResult(totalPower) + ' MW';
|
||||
}
|
||||
|
||||
[targetField, itemsField, timeField, clockField, basePowerField].forEach(f => f.input.addEventListener('input', calc));
|
||||
container.append(targetField.row, itemsField.row, timeField.row, clockField.row, basePowerField.row, machinesOutput.row, totalPowerOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||
if (data && data.calculator && data.calculator.satisfactory) {
|
||||
const s = data.calculator.satisfactory;
|
||||
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||
if (!data.calculator) data.calculator = {};
|
||||
data.calculator.satisfactory = { lastSubMode: _activeSubMode };
|
||||
await Store.set(Calculator.STORAGE_KEY, data);
|
||||
}
|
||||
|
||||
function renderSubMode(container) {
|
||||
container.textContent = '';
|
||||
switch (_activeSubMode) {
|
||||
case 'itemsPerMin': renderItemsPerMin(container); break;
|
||||
case 'power': renderPower(container); break;
|
||||
case 'machines': renderMachines(container); break;
|
||||
}
|
||||
}
|
||||
|
||||
Calculator.registerMode('satisfactory', {
|
||||
label: '⚙️',
|
||||
shortName: 'SAT',
|
||||
titleKey: 'calculator.tab.satisfactory',
|
||||
|
||||
render(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.gap = '8px';
|
||||
|
||||
loadState().then(() => {
|
||||
const subContent = document.createElement('div');
|
||||
subContent.className = 'calc-game-content';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'calc-game-subtabs';
|
||||
|
||||
SUB_MODES.forEach(mode => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
|
||||
btn.textContent = t('calculator.sat.tab.' + mode);
|
||||
btn.dataset.mode = mode;
|
||||
btn.addEventListener('click', () => {
|
||||
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_activeSubMode = mode;
|
||||
renderSubMode(subContent);
|
||||
saveState();
|
||||
});
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
|
||||
bodyEl.append(bar, subContent);
|
||||
renderSubMode(subContent);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
saveState();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,294 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — calc-scientific.js
|
||||
Scientific-Modus für Calculator Widget
|
||||
============================================= */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const FORMULAS = [
|
||||
{
|
||||
key: 'circle_area',
|
||||
fields: [{ key: 'radius', default: '' }],
|
||||
calc: (vals) => Math.PI * vals.radius * vals.radius
|
||||
},
|
||||
{
|
||||
key: 'circle_circumference',
|
||||
fields: [{ key: 'radius', default: '' }],
|
||||
calc: (vals) => 2 * Math.PI * vals.radius
|
||||
},
|
||||
{
|
||||
key: 'celsius_to_fahrenheit',
|
||||
fields: [{ key: 'temp', default: '' }],
|
||||
calc: (vals) => (vals.temp * 9 / 5) + 32
|
||||
},
|
||||
{
|
||||
key: 'fahrenheit_to_celsius',
|
||||
fields: [{ key: 'temp', default: '' }],
|
||||
calc: (vals) => (vals.temp - 32) * 5 / 9
|
||||
},
|
||||
{
|
||||
key: 'pythagoras',
|
||||
fields: [{ key: 'a', default: '' }, { key: 'b', default: '' }],
|
||||
calc: (vals) => Math.sqrt(vals.a * vals.a + vals.b * vals.b)
|
||||
},
|
||||
{
|
||||
key: 'percentage',
|
||||
fields: [{ key: 'value', default: '' }, { key: 'percent', default: '' }],
|
||||
calc: (vals) => vals.value * vals.percent / 100
|
||||
}
|
||||
];
|
||||
|
||||
let _keyboardExtHandler = null;
|
||||
|
||||
function renderSciButtons(container) {
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'calc-buttons calc-sci-buttons';
|
||||
|
||||
const buttons = [
|
||||
['√', 'sqrt', 'operator'],
|
||||
['x²', 'square', 'operator'],
|
||||
['xⁿ', 'power', 'operator'],
|
||||
['π', 'pi', 'operator'],
|
||||
['e', 'euler', 'operator'],
|
||||
['±', 'negate', 'operator']
|
||||
];
|
||||
|
||||
buttons.forEach(([label, value, cls]) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'calc-btn' + (cls ? ' ' + cls : '');
|
||||
btn.textContent = label;
|
||||
btn.type = 'button';
|
||||
btn.addEventListener('click', () => handleSciKey(value));
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
|
||||
container.appendChild(grid);
|
||||
}
|
||||
|
||||
function handleSciKey(key) {
|
||||
switch (key) {
|
||||
case 'sqrt':
|
||||
if (!Calculator._currentExpr && Calculator._lastResult) {
|
||||
Calculator._currentExpr = 'sqrt(' + Calculator._lastResult + ')';
|
||||
Calculator._lastResult = '';
|
||||
Calculator._updateDisplay();
|
||||
break;
|
||||
}
|
||||
Calculator._currentExpr += 'sqrt(';
|
||||
Calculator._updateDisplay();
|
||||
break;
|
||||
case 'square':
|
||||
if (!Calculator._currentExpr && Calculator._lastResult) {
|
||||
Calculator._currentExpr = Calculator._lastResult;
|
||||
Calculator._lastResult = '';
|
||||
}
|
||||
Calculator._currentExpr += '^2';
|
||||
Calculator._updateDisplay();
|
||||
break;
|
||||
case 'power':
|
||||
Calculator._handleKey('^');
|
||||
break;
|
||||
case 'pi':
|
||||
Calculator._currentExpr += '3.14159265359';
|
||||
Calculator._updateDisplay();
|
||||
break;
|
||||
case 'euler':
|
||||
Calculator._currentExpr += '2.71828182846';
|
||||
Calculator._updateDisplay();
|
||||
break;
|
||||
case 'negate':
|
||||
handleNegate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNegate() {
|
||||
const expr = Calculator._currentExpr;
|
||||
if (!expr && Calculator._lastResult) {
|
||||
const num = parseFloat(Calculator._lastResult);
|
||||
if (!isNaN(num)) {
|
||||
Calculator._currentExpr = String(-num);
|
||||
Calculator._lastResult = '';
|
||||
Calculator._updateDisplay();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const match = expr.match(/(-?\d*\.?\d+)$/);
|
||||
if (match) {
|
||||
const num = parseFloat(match[1]);
|
||||
const negated = String(-num);
|
||||
Calculator._currentExpr = expr.slice(0, expr.length - match[1].length) + negated;
|
||||
Calculator._updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function renderFormulaHelper(container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'calc-formula-helper';
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'calc-formula-label';
|
||||
label.textContent = t('calculator.sci.formulas');
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.className = 'calc-formula-select';
|
||||
|
||||
const emptyOpt = document.createElement('option');
|
||||
emptyOpt.value = '';
|
||||
emptyOpt.textContent = t('calculator.sci.select_formula');
|
||||
select.appendChild(emptyOpt);
|
||||
|
||||
FORMULAS.forEach((f, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(i);
|
||||
opt.textContent = t('calculator.sci.formula.' + f.key);
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
const inputsContainer = document.createElement('div');
|
||||
inputsContainer.className = 'calc-formula-inputs';
|
||||
|
||||
const resultContainer = document.createElement('div');
|
||||
resultContainer.className = 'calc-formula-result';
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
while (inputsContainer.firstChild) {
|
||||
inputsContainer.removeChild(inputsContainer.firstChild);
|
||||
}
|
||||
resultContainer.textContent = '';
|
||||
|
||||
const idx = parseInt(select.value, 10);
|
||||
if (isNaN(idx)) return;
|
||||
|
||||
const formula = FORMULAS[idx];
|
||||
renderFormulaInputs(formula, inputsContainer, resultContainer);
|
||||
});
|
||||
|
||||
wrapper.append(label, select, inputsContainer, resultContainer);
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function renderFormulaInputs(formula, inputsEl, resultEl) {
|
||||
const inputs = {};
|
||||
|
||||
formula.fields.forEach(field => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-formula-row';
|
||||
|
||||
const lbl = document.createElement('label');
|
||||
lbl.textContent = t('calculator.sci.field.' + field.key);
|
||||
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'number';
|
||||
inp.className = 'calc-formula-input';
|
||||
inp.placeholder = '0';
|
||||
inp.step = 'any';
|
||||
inputs[field.key] = inp;
|
||||
|
||||
inp.addEventListener('input', () => {
|
||||
recalcFormula(formula, inputs, resultEl);
|
||||
});
|
||||
|
||||
row.append(lbl, inp);
|
||||
inputsEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function recalcFormula(formula, inputs, resultEl) {
|
||||
const vals = {};
|
||||
let allValid = true;
|
||||
|
||||
for (const field of formula.fields) {
|
||||
const v = parseFloat(inputs[field.key].value);
|
||||
if (isNaN(v)) { allValid = false; break; }
|
||||
vals[field.key] = v;
|
||||
}
|
||||
|
||||
if (!allValid) {
|
||||
resultEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const result = formula.calc(vals);
|
||||
if (result === null || !isFinite(result)) {
|
||||
resultEl.textContent = t('calculator.error');
|
||||
return;
|
||||
}
|
||||
|
||||
resultEl.textContent = '= ' + Calculator._formatResult(result);
|
||||
}
|
||||
|
||||
function bindSciKeyboard(widgetEl) {
|
||||
_keyboardExtHandler = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
if (e.target.contentEditable === 'true') return;
|
||||
|
||||
if (e.key === 'p') {
|
||||
handleSciKey('pi');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
} else if (e.key === '^') {
|
||||
handleSciKey('power');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
widgetEl.addEventListener('keydown', _keyboardExtHandler);
|
||||
}
|
||||
|
||||
Calculator.registerMode('scientific', {
|
||||
label: '📐',
|
||||
shortName: 'Sci',
|
||||
titleKey: 'calculator.tab.scientific',
|
||||
|
||||
render(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.flex = '1';
|
||||
bodyEl.style.overflow = 'hidden';
|
||||
|
||||
const display = document.createElement('div');
|
||||
display.className = 'calc-display';
|
||||
|
||||
const exprEl = document.createElement('div');
|
||||
exprEl.className = 'calc-expression';
|
||||
Calculator._displayExprEl = exprEl;
|
||||
|
||||
const resultEl = document.createElement('div');
|
||||
resultEl.className = 'calc-result';
|
||||
resultEl.textContent = Calculator._lastResult || '0';
|
||||
Calculator._displayResultEl = resultEl;
|
||||
|
||||
display.append(exprEl, resultEl);
|
||||
|
||||
const sciSection = document.createElement('div');
|
||||
renderSciButtons(sciSection);
|
||||
|
||||
const stdButtons = Calculator._createButtons();
|
||||
const historyEl = Calculator._createHistoryPanel();
|
||||
|
||||
const formulaSection = document.createElement('div');
|
||||
renderFormulaHelper(formulaSection);
|
||||
|
||||
bodyEl.append(display, sciSection, stdButtons, historyEl, formulaSection);
|
||||
Calculator._updateDisplay();
|
||||
|
||||
const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
|
||||
if (entry) bindSciKeyboard(entry.el);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (_keyboardExtHandler) {
|
||||
const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
|
||||
if (entry) {
|
||||
entry.el.removeEventListener('keydown', _keyboardExtHandler);
|
||||
}
|
||||
_keyboardExtHandler = null;
|
||||
}
|
||||
Calculator._displayExprEl = null;
|
||||
Calculator._displayResultEl = null;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,361 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — calc-stationeers.js
|
||||
Stationeers Calculator Modus
|
||||
============================================= */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const R = 8314.46261815324;
|
||||
const COMBUSTION_ENERGY = 563452;
|
||||
const HEAT_CAP_PURE_FUEL = 61.9;
|
||||
const HEAT_CAP_DELTA = 172.615;
|
||||
const BATTERY_CAPACITY = 50000;
|
||||
|
||||
const HEAT_CAPS = [
|
||||
{ gas: 'O\u2082', cp: 21.1 },
|
||||
{ gas: 'H\u2082', cp: 20.4 },
|
||||
{ gas: 'CO\u2082', cp: 28.2 },
|
||||
{ gas: 'N\u2082', cp: 20.6 },
|
||||
{ gas: 'H\u2082O', cp: 72.0 },
|
||||
{ gas: 'N\u2082O', cp: 23.0 },
|
||||
{ gas: 'Pollutant', cp: 24.8 }
|
||||
];
|
||||
|
||||
const GAS_VARS = ['P', 'V', 'n', 'T'];
|
||||
const SUB_MODES = ['gas', 'furnace', 'solar', 'atmo'];
|
||||
let _activeSubMode = 'gas';
|
||||
|
||||
function createField(labelKey, defaultVal, opts) {
|
||||
opts = opts || {};
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = t(labelKey);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'calc-game-input';
|
||||
input.value = defaultVal;
|
||||
if (opts.step) input.step = opts.step;
|
||||
if (opts.min !== undefined) input.min = opts.min;
|
||||
if (opts.max !== undefined) input.max = opts.max;
|
||||
if (opts.disabled) input.disabled = true;
|
||||
row.append(label, input);
|
||||
return { row, input };
|
||||
}
|
||||
|
||||
function createOutput(labelKey) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'calc-game-output';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = t(labelKey);
|
||||
const value = document.createElement('span');
|
||||
value.className = 'calc-game-value';
|
||||
row.append(label, value);
|
||||
return { row, value };
|
||||
}
|
||||
|
||||
function renderGas(container) {
|
||||
const solveRow = document.createElement('div');
|
||||
solveRow.className = 'calc-game-field';
|
||||
const solveLabel = document.createElement('label');
|
||||
solveLabel.textContent = t('calculator.sta.solve_for');
|
||||
const solveSelect = document.createElement('select');
|
||||
solveSelect.className = 'calc-game-input';
|
||||
|
||||
GAS_VARS.forEach(v => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v;
|
||||
opt.textContent = t('calculator.sta.var.' + v);
|
||||
solveSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
solveRow.append(solveLabel, solveSelect);
|
||||
container.appendChild(solveRow);
|
||||
|
||||
const fields = {};
|
||||
const defaults = { P: 101.325, V: 1000, n: 1, T: 293.15 };
|
||||
|
||||
GAS_VARS.forEach(v => {
|
||||
const f = createField(
|
||||
'calculator.sta.var.' + v + '_label',
|
||||
defaults[v],
|
||||
{ step: 'any' }
|
||||
);
|
||||
fields[v] = f;
|
||||
container.appendChild(f.row);
|
||||
});
|
||||
|
||||
const tempHelper = document.createElement('div');
|
||||
tempHelper.className = 'calc-game-hint';
|
||||
container.appendChild(tempHelper);
|
||||
|
||||
const resultOutput = createOutput('calculator.sta.result');
|
||||
container.appendChild(resultOutput.row);
|
||||
|
||||
function calc() {
|
||||
const solveFor = solveSelect.value;
|
||||
|
||||
GAS_VARS.forEach(v => {
|
||||
fields[v].input.disabled = (v === solveFor);
|
||||
fields[v].input.style.opacity = (v === solveFor) ? '0.5' : '1';
|
||||
});
|
||||
|
||||
const P_kPa = parseFloat(fields.P.input.value) || 0;
|
||||
const P = P_kPa * 1000;
|
||||
const V = parseFloat(fields.V.input.value) || 0;
|
||||
const n = parseFloat(fields.n.input.value) || 0;
|
||||
const T = parseFloat(fields.T.input.value) || 0;
|
||||
|
||||
let result = null;
|
||||
let unit = '';
|
||||
|
||||
switch (solveFor) {
|
||||
case 'P':
|
||||
if (V > 0) { result = (n * R * T) / V; result /= 1000; unit = 'kPa'; }
|
||||
break;
|
||||
case 'V':
|
||||
if (P > 0) { result = (n * R * T) / P; unit = 'L'; }
|
||||
break;
|
||||
case 'n':
|
||||
if (R * T > 0) { result = (P * V) / (R * T); unit = 'mol'; }
|
||||
break;
|
||||
case 'T':
|
||||
if (n * R > 0) { result = (P * V) / (n * R); unit = 'K'; }
|
||||
break;
|
||||
}
|
||||
|
||||
if (result !== null && isFinite(result)) {
|
||||
fields[solveFor].input.value = Calculator._formatResult(result);
|
||||
resultOutput.value.textContent = Calculator._formatResult(result) + ' ' + unit;
|
||||
} else {
|
||||
resultOutput.value.textContent = '-';
|
||||
}
|
||||
|
||||
const tempVal = parseFloat(fields.T.input.value) || 0;
|
||||
tempHelper.textContent = Calculator._formatResult(tempVal - 273.15) + ' \u00B0C';
|
||||
}
|
||||
|
||||
GAS_VARS.forEach(v => {
|
||||
fields[v].input.addEventListener('input', calc);
|
||||
});
|
||||
solveSelect.addEventListener('change', calc);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderFurnace(container) {
|
||||
const fuelField = createField('calculator.sta.fuel_ratio', 0.5, { step: 0.01, min: 0, max: 1 });
|
||||
const tempField = createField('calculator.sta.start_temp', 293.15, { step: 1, min: 0 });
|
||||
const pressField = createField('calculator.sta.start_pressure', 101.325, { step: 0.1, min: 0 });
|
||||
|
||||
const tempOutput = createOutput('calculator.sta.temp_after');
|
||||
const pressOutput = createOutput('calculator.sta.pressure_after');
|
||||
const warningEl = document.createElement('div');
|
||||
warningEl.className = 'calc-game-warning';
|
||||
|
||||
function calc() {
|
||||
const fuel = parseFloat(fuelField.input.value) || 0;
|
||||
const T_vor = parseFloat(tempField.input.value) || 293.15;
|
||||
const P_vor = parseFloat(pressField.input.value) || 101.325;
|
||||
|
||||
warningEl.textContent = '';
|
||||
if (fuel < 0.05) {
|
||||
warningEl.textContent = t('calculator.sta.warn_low_fuel');
|
||||
}
|
||||
if (P_vor < 10) {
|
||||
warningEl.textContent += (warningEl.textContent ? ' ' : '') + t('calculator.sta.warn_low_pressure');
|
||||
}
|
||||
|
||||
const specificHeat = HEAT_CAP_PURE_FUEL;
|
||||
const T_nach = (T_vor * specificHeat + fuel * COMBUSTION_ENERGY) / (specificHeat + fuel * HEAT_CAP_DELTA);
|
||||
const P_nach = P_vor * T_nach * (1 + 5.7 * fuel) / T_vor;
|
||||
|
||||
tempOutput.value.textContent = Calculator._formatResult(T_nach) + ' K (' + Calculator._formatResult(T_nach - 273.15) + ' \u00B0C)';
|
||||
pressOutput.value.textContent = Calculator._formatResult(P_nach) + ' kPa';
|
||||
}
|
||||
|
||||
[fuelField, tempField, pressField].forEach(f => f.input.addEventListener('input', calc));
|
||||
|
||||
container.append(fuelField.row, tempField.row, pressField.row, warningEl, tempOutput.row, pressOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderSolar(container) {
|
||||
const panelField = createField('calculator.sta.panels', 12, { step: 1, min: 1 });
|
||||
const wattField = createField('calculator.sta.watts_per_panel', 500, { step: 10, min: 1 });
|
||||
const dayField = createField('calculator.sta.day_length', 600, { step: 1, min: 1 });
|
||||
const nightField = createField('calculator.sta.night_length', 600, { step: 1, min: 1 });
|
||||
const consumeField = createField('calculator.sta.consumption', 2000, { step: 10, min: 0 });
|
||||
|
||||
const genOutput = createOutput('calculator.sta.generation');
|
||||
const surplusOutput = createOutput('calculator.sta.surplus');
|
||||
const nightOutput = createOutput('calculator.sta.night_energy');
|
||||
const battOutput = createOutput('calculator.sta.batteries_needed');
|
||||
|
||||
function calc() {
|
||||
const panels = parseFloat(panelField.input.value) || 0;
|
||||
const wpp = parseFloat(wattField.input.value) || 0;
|
||||
const nightLen = parseFloat(nightField.input.value) || 0;
|
||||
const consume = parseFloat(consumeField.input.value) || 0;
|
||||
|
||||
const generation = panels * wpp;
|
||||
const surplus = generation - consume;
|
||||
const nightEnergy = consume * nightLen;
|
||||
const batteries = nightEnergy > 0 ? Math.ceil(nightEnergy / BATTERY_CAPACITY) : 0;
|
||||
|
||||
genOutput.value.textContent = Calculator._formatResult(generation) + ' W';
|
||||
|
||||
surplusOutput.value.textContent = Calculator._formatResult(surplus) + ' W';
|
||||
if (surplus < 0) {
|
||||
surplusOutput.value.style.color = 'var(--danger)';
|
||||
} else {
|
||||
surplusOutput.value.style.color = '';
|
||||
}
|
||||
|
||||
nightOutput.value.textContent = Calculator._formatResult(nightEnergy) + ' Ws';
|
||||
battOutput.value.textContent = batteries;
|
||||
}
|
||||
|
||||
[panelField, wattField, dayField, nightField, consumeField].forEach(f => f.input.addEventListener('input', calc));
|
||||
|
||||
container.append(panelField.row, wattField.row, dayField.row, nightField.row, consumeField.row,
|
||||
genOutput.row, surplusOutput.row, nightOutput.row, battOutput.row);
|
||||
calc();
|
||||
}
|
||||
|
||||
function renderAtmo(container) {
|
||||
const targetField = createField('calculator.sta.target_temp', 293.15, { step: 1 });
|
||||
const gas1Field = createField('calculator.sta.gas1_temp', 200, { step: 1 });
|
||||
const gas2Field = createField('calculator.sta.gas2_temp', 400, { step: 1 });
|
||||
|
||||
const m1Output = createOutput('calculator.sta.mixer_input1');
|
||||
const m2Output = createOutput('calculator.sta.mixer_input2');
|
||||
|
||||
function calc() {
|
||||
const T0 = parseFloat(targetField.input.value) || 0;
|
||||
const T1 = parseFloat(gas1Field.input.value) || 0;
|
||||
const T2 = parseFloat(gas2Field.input.value) || 0;
|
||||
|
||||
const denom = Math.abs(T1 - T0) + Math.abs(T2 - T0);
|
||||
if (denom === 0) {
|
||||
m1Output.value.textContent = '50%';
|
||||
m2Output.value.textContent = '50%';
|
||||
return;
|
||||
}
|
||||
|
||||
const M1 = Math.abs(T2 - T0) / denom;
|
||||
const M2 = 1 - M1;
|
||||
|
||||
m1Output.value.textContent = Calculator._formatResult(M1 * 100) + '%';
|
||||
m2Output.value.textContent = Calculator._formatResult(M2 * 100) + '%';
|
||||
}
|
||||
|
||||
[targetField, gas1Field, gas2Field].forEach(f => f.input.addEventListener('input', calc));
|
||||
|
||||
container.append(targetField.row, gas1Field.row, gas2Field.row, m1Output.row, m2Output.row);
|
||||
calc();
|
||||
|
||||
const details = document.createElement('details');
|
||||
details.className = 'calc-game-details';
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = t('calculator.sta.heat_cap_ref');
|
||||
details.appendChild(summary);
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'calc-game-table';
|
||||
|
||||
const thead = document.createElement('thead');
|
||||
const headerRow = document.createElement('tr');
|
||||
const thGas = document.createElement('th');
|
||||
thGas.textContent = t('calculator.sta.gas');
|
||||
const thCp = document.createElement('th');
|
||||
thCp.textContent = 'Cp (J/mol\u00B7K)';
|
||||
headerRow.append(thGas, thCp);
|
||||
thead.appendChild(headerRow);
|
||||
|
||||
const tbody = document.createElement('tbody');
|
||||
HEAT_CAPS.forEach(entry => {
|
||||
const tr = document.createElement('tr');
|
||||
const tdGas = document.createElement('td');
|
||||
tdGas.textContent = entry.gas;
|
||||
const tdCp = document.createElement('td');
|
||||
tdCp.textContent = entry.cp;
|
||||
tr.append(tdGas, tdCp);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.append(thead, tbody);
|
||||
details.appendChild(table);
|
||||
container.appendChild(details);
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||
if (data && data.calculator && data.calculator.stationeers) {
|
||||
const s = data.calculator.stationeers;
|
||||
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState() {
|
||||
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||
if (!data.calculator) data.calculator = {};
|
||||
data.calculator.stationeers = { lastSubMode: _activeSubMode };
|
||||
await Store.set(Calculator.STORAGE_KEY, data);
|
||||
}
|
||||
|
||||
function renderSubMode(container) {
|
||||
container.textContent = '';
|
||||
switch (_activeSubMode) {
|
||||
case 'gas': renderGas(container); break;
|
||||
case 'furnace': renderFurnace(container); break;
|
||||
case 'solar': renderSolar(container); break;
|
||||
case 'atmo': renderAtmo(container); break;
|
||||
}
|
||||
}
|
||||
|
||||
Calculator.registerMode('stationeers', {
|
||||
label: '\uD83D\uDE80',
|
||||
shortName: 'STA',
|
||||
titleKey: 'calculator.tab.stationeers',
|
||||
|
||||
render(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.gap = '8px';
|
||||
|
||||
loadState().then(() => {
|
||||
const subContent = document.createElement('div');
|
||||
subContent.className = 'calc-game-content';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'calc-game-subtabs';
|
||||
|
||||
SUB_MODES.forEach(mode => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
|
||||
btn.textContent = t('calculator.sta.tab.' + mode);
|
||||
btn.dataset.mode = mode;
|
||||
btn.addEventListener('click', () => {
|
||||
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_activeSubMode = mode;
|
||||
renderSubMode(subContent);
|
||||
saveState();
|
||||
});
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
|
||||
bodyEl.append(bar, subContent);
|
||||
renderSubMode(subContent);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
saveState();
|
||||
}
|
||||
});
|
||||
})();
|
||||
+261
-61
@@ -17,6 +17,22 @@ 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 ----
|
||||
|
||||
@@ -27,6 +43,9 @@ 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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -36,20 +55,19 @@ 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);
|
||||
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)
|
||||
};
|
||||
if (!data.calculator) data.calculator = {};
|
||||
data.calculator.x = widgetState ? widgetState.x : 400;
|
||||
data.calculator.y = widgetState ? widgetState.y : 120;
|
||||
data.calculator.width = widgetState ? widgetState.width : 280;
|
||||
data.calculator.height = widgetState ? widgetState.height : 400;
|
||||
data.calculator.open = this._isOpen;
|
||||
data.calculator.activeMode = this._activeMode;
|
||||
data.calculator.history = this._history.slice(0, this.MAX_HISTORY);
|
||||
|
||||
await Store.set(this.STORAGE_KEY, { notes: notesState, calculator: calcData });
|
||||
await Store.set(this.STORAGE_KEY, data);
|
||||
},
|
||||
|
||||
// ---- WIDGET LIFECYCLE ----
|
||||
@@ -69,7 +87,7 @@ const Calculator = {
|
||||
|
||||
const widgetId = WidgetManager.create('calculator', {
|
||||
id: this.WIDGET_ID,
|
||||
title: 'Taschenrechner',
|
||||
title: t('calculator.title'),
|
||||
x: saved.x || 400,
|
||||
y: saved.y || 120,
|
||||
width: saved.width || 280,
|
||||
@@ -113,8 +131,13 @@ 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();
|
||||
@@ -123,14 +146,136 @@ const Calculator = {
|
||||
// ---- UI RENDERING ----
|
||||
|
||||
/**
|
||||
* Calculator-Body rendern (in Widget-Body einfuegen)
|
||||
* Calculator-Body rendern: Tab-Bar + aktiver Modus
|
||||
* @param {HTMLElement} bodyEl
|
||||
*/
|
||||
renderBody(bodyEl) {
|
||||
bodyEl.textContent = '';
|
||||
bodyEl.style.padding = '0';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.height = '100%';
|
||||
|
||||
// Tab-Bar
|
||||
const tabBar = document.createElement('div');
|
||||
tabBar.className = 'calc-tab-bar';
|
||||
this._tabBarEl = tabBar;
|
||||
this._renderTabBar();
|
||||
|
||||
// Mode-Body Container
|
||||
const modeBody = document.createElement('div');
|
||||
modeBody.className = 'calc-mode-body';
|
||||
|
||||
bodyEl.append(tabBar, modeBody);
|
||||
|
||||
// Aktiven Modus rendern
|
||||
const mode = this._modes.get(this._activeMode);
|
||||
if (mode) {
|
||||
mode.render(modeBody);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Tab-Bar mit Buttons aus _modes Map befüllen
|
||||
*/
|
||||
_renderTabBar() {
|
||||
if (!this._tabBarEl) return;
|
||||
while (this._tabBarEl.firstChild) {
|
||||
this._tabBarEl.removeChild(this._tabBarEl.firstChild);
|
||||
}
|
||||
|
||||
this._modes.forEach((config, name) => {
|
||||
const tab = document.createElement('button');
|
||||
tab.type = 'button';
|
||||
tab.className = 'calc-tab' + (name === this._activeMode ? ' active' : '');
|
||||
tab.dataset.mode = name;
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'calc-tab-icon';
|
||||
icon.textContent = config.label;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'calc-tab-label';
|
||||
label.textContent = config.shortName;
|
||||
|
||||
tab.append(icon, label);
|
||||
tab.addEventListener('click', () => this.switchMode(name));
|
||||
this._tabBarEl.appendChild(tab);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Aktiven Tab visuell markieren (ohne Neuaufbau)
|
||||
*/
|
||||
_updateTabBar() {
|
||||
if (!this._tabBarEl) return;
|
||||
const tabs = this._tabBarEl.querySelectorAll('.calc-tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.mode === this._activeMode);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Modus wechseln
|
||||
* @param {string} name - Ziel-Modus
|
||||
*/
|
||||
async switchMode(name) {
|
||||
if (name === this._activeMode) return;
|
||||
const mode = this._modes.get(name);
|
||||
if (!mode) return;
|
||||
|
||||
// Alten Modus aufräumen
|
||||
const oldMode = this._modes.get(this._activeMode);
|
||||
if (oldMode && oldMode.destroy) oldMode.destroy();
|
||||
|
||||
this._activeMode = name;
|
||||
|
||||
// Mode-Body leeren und neu rendern
|
||||
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||
if (!entry) return;
|
||||
const modeBody = entry.el.querySelector('.calc-mode-body');
|
||||
if (!modeBody) return;
|
||||
modeBody.textContent = '';
|
||||
mode.render(modeBody);
|
||||
|
||||
// Tab-UI aktualisieren
|
||||
this._updateTabBar();
|
||||
|
||||
// Auto-Resize für komplexe Modi
|
||||
const isComplex = name !== 'standard';
|
||||
if (isComplex && entry) {
|
||||
const state = entry.state;
|
||||
if (state) {
|
||||
const newW = Math.max(state.width, 320);
|
||||
const newH = Math.max(state.height, 480);
|
||||
if (newW !== state.width || newH !== state.height) {
|
||||
entry.el.style.width = newW + 'px';
|
||||
entry.el.style.height = newH + 'px';
|
||||
state.width = newW;
|
||||
state.height = newH;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard neu binden
|
||||
this._unbindKeyboard();
|
||||
if (name === 'standard' || name === 'scientific') {
|
||||
if (entry) this._bindKeyboard(entry.el);
|
||||
}
|
||||
|
||||
await this.save();
|
||||
},
|
||||
|
||||
/**
|
||||
* Standard-Modus UI rendern
|
||||
* @param {HTMLElement} bodyEl
|
||||
*/
|
||||
_renderStandardMode(bodyEl) {
|
||||
bodyEl.style.padding = '8px';
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.flex = '1';
|
||||
bodyEl.style.overflow = 'hidden';
|
||||
|
||||
// Display
|
||||
const display = document.createElement('div');
|
||||
@@ -214,7 +359,7 @@ const Calculator = {
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'calc-history-title';
|
||||
title.textContent = 'History';
|
||||
title.textContent = t('calculator.history');
|
||||
container.appendChild(title);
|
||||
|
||||
this._renderHistoryItems(container);
|
||||
@@ -297,7 +442,8 @@ 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;
|
||||
@@ -305,7 +451,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;
|
||||
@@ -315,7 +461,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;
|
||||
@@ -345,7 +491,7 @@ const Calculator = {
|
||||
|
||||
const result = this._evaluate(this._currentExpr);
|
||||
if (result === null) {
|
||||
this._lastResult = 'Fehler';
|
||||
this._lastResult = t('calculator.error');
|
||||
this._updateDisplay();
|
||||
return;
|
||||
}
|
||||
@@ -381,7 +527,7 @@ const Calculator = {
|
||||
_evaluate(expr) {
|
||||
try {
|
||||
// Nur erlaubte Zeichen
|
||||
const sanitized = expr.replace(/[^0-9+\-*/.%()]/g, '');
|
||||
const sanitized = expr.replace(/[^0-9+\-*/.%()^a-z]/g, '');
|
||||
if (!sanitized) return null;
|
||||
|
||||
const tokens = this._tokenize(sanitized);
|
||||
@@ -405,6 +551,13 @@ 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 = '';
|
||||
@@ -443,6 +596,13 @@ const Calculator = {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Potenz-Operator
|
||||
if (ch === '^') {
|
||||
tokens.push({ type: 'op', value: '^' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Klammern
|
||||
if (ch === '(' || ch === ')') {
|
||||
tokens.push({ type: 'paren', value: ch });
|
||||
@@ -450,6 +610,11 @@ const Calculator = {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unbekannte Buchstaben
|
||||
if (/[a-z]/.test(ch)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unbekanntes Zeichen
|
||||
return null;
|
||||
}
|
||||
@@ -459,6 +624,7 @@ const Calculator = {
|
||||
|
||||
/**
|
||||
* Rekursiver Descent Parser mit Operator-Precedence
|
||||
* Hierarchie: parseExpr (+/-) → parseTerm (*\/%) → parsePower (^) → parseFactor
|
||||
* @param {Array} tokens
|
||||
* @returns {number|null}
|
||||
*/
|
||||
@@ -468,36 +634,32 @@ 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 t = peek();
|
||||
if (!t || t.type !== 'op' || (t.value !== '+' && t.value !== '-')) break;
|
||||
const tk = peek();
|
||||
if (!tk || tk.type !== 'op' || (tk.value !== '+' && tk.value !== '-')) break;
|
||||
consume();
|
||||
const right = parseTerm();
|
||||
if (right === null) return null;
|
||||
left = t.value === '+' ? left + right : left - right;
|
||||
left = tk.value === '+' ? left + right : left - right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
// Term: Factor (('*' | '/' | '%') Factor)*
|
||||
function parseTerm() {
|
||||
let left = parseFactor();
|
||||
let left = parsePower();
|
||||
if (left === null) return null;
|
||||
|
||||
while (pos < tokens.length) {
|
||||
const t = peek();
|
||||
if (!t || t.type !== 'op' || (t.value !== '*' && t.value !== '/' && t.value !== '%')) break;
|
||||
const tk = peek();
|
||||
if (!tk || tk.type !== 'op' || (tk.value !== '*' && tk.value !== '/' && tk.value !== '%')) break;
|
||||
consume();
|
||||
const right = parseFactor();
|
||||
const right = parsePower();
|
||||
if (right === null) return null;
|
||||
if (t.value === '*') {
|
||||
if (tk.value === '*') {
|
||||
left = left * right;
|
||||
} else if (t.value === '/') {
|
||||
} else if (tk.value === '/') {
|
||||
if (right === 0) return null;
|
||||
left = left / right;
|
||||
} else {
|
||||
@@ -507,17 +669,51 @@ const Calculator = {
|
||||
return left;
|
||||
}
|
||||
|
||||
// Factor: Number | '(' Expression ')'
|
||||
function parseFactor() {
|
||||
const t = peek();
|
||||
if (!t) return null;
|
||||
|
||||
if (t.type === 'number') {
|
||||
// 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();
|
||||
return t.value;
|
||||
const exp = parsePower(); // Rechts-assoziativ!
|
||||
if (exp === null) return null;
|
||||
return Math.pow(base, exp);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// Factor: func '(' Expression ')' | Number | '(' Expression ')'
|
||||
function parseFactor() {
|
||||
const tk = peek();
|
||||
if (!tk) return null;
|
||||
|
||||
// Funktion: sqrt(...)
|
||||
if (tk.type === 'func') {
|
||||
const funcName = tk.value;
|
||||
consume();
|
||||
const open = peek();
|
||||
if (!open || open.type !== 'paren' || open.value !== '(') return null;
|
||||
consume();
|
||||
const val = parseExpr();
|
||||
if (val === null) return null;
|
||||
const close = peek();
|
||||
if (close && close.type === 'paren' && close.value === ')') {
|
||||
consume();
|
||||
}
|
||||
if (funcName === 'sqrt') {
|
||||
if (val < 0) return null; // Negativer Radikand nicht erlaubt
|
||||
return Math.sqrt(val);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (t.type === 'paren' && t.value === '(') {
|
||||
if (tk.type === 'number') {
|
||||
consume();
|
||||
return tk.value;
|
||||
}
|
||||
|
||||
if (tk.type === 'paren' && tk.value === '(') {
|
||||
consume();
|
||||
const val = parseExpr();
|
||||
if (val === null) return null;
|
||||
@@ -562,7 +758,8 @@ const Calculator = {
|
||||
_formatExpression(expr) {
|
||||
return expr
|
||||
.replace(/\*/g, '\u00D7')
|
||||
.replace(/\//g, '\u00F7');
|
||||
.replace(/\//g, '\u00F7')
|
||||
.replace(/sqrt\(/g, '\u221A(');
|
||||
},
|
||||
|
||||
// ---- DISPLAY ----
|
||||
@@ -683,47 +880,50 @@ 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();
|
||||
}
|
||||
|
||||
// Close-Event abfangen: WidgetManager.close() ueberschreiben
|
||||
const origClose = WidgetManager.close.bind(WidgetManager);
|
||||
// Widget-Lifecycle-Events
|
||||
const self = this;
|
||||
WidgetManager.close = function(id) {
|
||||
origClose(id);
|
||||
if (id === self.WIDGET_ID) {
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self.onClose();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Minimize-Event abfangen
|
||||
const origMinimize = WidgetManager.minimize.bind(WidgetManager);
|
||||
WidgetManager.minimize = async function(id) {
|
||||
await origMinimize(id);
|
||||
if (id === self.WIDGET_ID) {
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = false;
|
||||
await self.save();
|
||||
self.save();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Open-Event abfangen
|
||||
const origOpen = WidgetManager.openWidget.bind(WidgetManager);
|
||||
WidgetManager.openWidget = async function(id) {
|
||||
await origOpen(id);
|
||||
if (id === self.WIDGET_ID) {
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = true;
|
||||
// Body neu rendern (war durch minimize entfernt)
|
||||
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||
if (body && body.children.length === 0) {
|
||||
self.renderBody(body);
|
||||
}
|
||||
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||
if (entry) self._bindKeyboard(entry.el);
|
||||
await self.save();
|
||||
self.save();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+60
-31
@@ -9,11 +9,26 @@ 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: '1.11.1',
|
||||
version: '2.1.0',
|
||||
exported: new Date().toISOString(),
|
||||
boards,
|
||||
settings,
|
||||
@@ -37,23 +52,26 @@ function initDataButtons() {
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
if (!Array.isArray(data.boards)) throw new Error('Ungültiges Format');
|
||||
const validBoards = data.boards.filter(b => {
|
||||
if (!b || typeof b.title !== 'string' || !Array.isArray(b.bookmarks)) return false;
|
||||
b.id = b.id || uid();
|
||||
b.blurred = !!b.blurred;
|
||||
b.bookmarks = b.bookmarks.filter(bm => {
|
||||
if (!bm || typeof bm.title !== 'string' || typeof bm.url !== 'string') return false;
|
||||
bm.id = bm.id || uid();
|
||||
bm.desc = bm.desc || '';
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
if (validBoards.length === 0) throw new Error('Keine gültigen Boards gefunden');
|
||||
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'));
|
||||
const ok = await HellionDialog.confirm(
|
||||
`${validBoards.length} Boards importieren? Bestehende Daten bleiben erhalten.`,
|
||||
{ type: 'info', title: 'JSON Import' }
|
||||
t('data.import_confirm', { count: validBoards.length }),
|
||||
{ type: 'info', title: t('data.import_confirm.title') }
|
||||
);
|
||||
if (!ok) return;
|
||||
boards = [...boards, ...validBoards];
|
||||
@@ -65,18 +83,26 @@ 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 => {
|
||||
if (!n || !n.id || !n.template) return false;
|
||||
n.checklistItems = Array.isArray(n.checklistItems) ? n.checklistItems : [];
|
||||
return true;
|
||||
});
|
||||
const importNotes = data.notes
|
||||
.filter(n => n && n.id && n.template)
|
||||
.map(n => ({
|
||||
id: n.id,
|
||||
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
|
||||
title: String(n.title || '').slice(0, 200),
|
||||
content: String(n.content || '').slice(0, 5000),
|
||||
x: typeof n.x === 'number' ? n.x : 120,
|
||||
y: typeof n.y === 'number' ? n.y : 80,
|
||||
width: typeof n.width === 'number' ? n.width : 280,
|
||||
height: typeof n.height === 'number' ? n.height : 220,
|
||||
open: n.open !== false,
|
||||
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
|
||||
}));
|
||||
// Limit beachten
|
||||
const spaceLeft = Notes.MAX_NOTES - existingNotes.length;
|
||||
const toImport = importNotes.slice(0, spaceLeft);
|
||||
if (toImport.length > 0) {
|
||||
const merged = [...existingNotes, ...toImport];
|
||||
existingWidgets.notes = merged;
|
||||
Notes._notes = merged;
|
||||
notesImported = toImport.length;
|
||||
}
|
||||
}
|
||||
@@ -90,7 +116,6 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -104,7 +129,6 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -112,15 +136,20 @@ function initDataButtons() {
|
||||
// Gemeinsam speichern
|
||||
await Store.set('widgetStates', existingWidgets);
|
||||
|
||||
const noteMsg = notesImported > 0 ? ` + ${notesImported} Note(s)` : '';
|
||||
const calcMsg = calcImported ? ' + Calculator-History' : '';
|
||||
const timerMsg = timerImported ? ' + Timer-Presets' : '';
|
||||
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
|
||||
if (notesImported > 0) await Notes.init();
|
||||
if (calcImported) await Calculator.load();
|
||||
if (timerImported) await Timer.load();
|
||||
|
||||
const noteMsg = notesImported > 0 ? t('data.notes_suffix', { count: notesImported }) : '';
|
||||
const calcMsg = calcImported ? t('data.calc_suffix') : '';
|
||||
const timerMsg = timerImported ? t('data.timer_suffix') : '';
|
||||
await HellionDialog.alert(
|
||||
`${validBoards.length} Board(s)${noteMsg}${calcMsg}${timerMsg} erfolgreich importiert.`,
|
||||
{ type: 'success', title: 'Import erfolgreich' }
|
||||
t('data.import_success', { boards: validBoards.length, notes: noteMsg, calc: calcMsg, timer: timerMsg }),
|
||||
{ type: 'success', title: t('data.import_success.title') }
|
||||
);
|
||||
} catch (err) {
|
||||
await HellionDialog.alert('Fehler beim Import: ' + err.message, { type: 'danger', title: 'Import fehlgeschlagen' });
|
||||
await HellionDialog.alert(t('data.import_error', { error: err.message }), { type: 'danger', title: t('data.import_error.title') });
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
+5
-5
@@ -126,8 +126,8 @@ const HellionDialog = {
|
||||
const opts = options || {};
|
||||
return this._show({
|
||||
message,
|
||||
title: opts.title || 'Hinweis',
|
||||
confirmText: opts.confirmText || 'OK',
|
||||
title: opts.title || t('dialog.default_title'),
|
||||
confirmText: opts.confirmText || t('dialog.ok'),
|
||||
cancelText: '',
|
||||
type: opts.type || 'info',
|
||||
isConfirm: false
|
||||
@@ -144,9 +144,9 @@ const HellionDialog = {
|
||||
const opts = options || {};
|
||||
return this._show({
|
||||
message,
|
||||
title: opts.title || 'Bestätigung',
|
||||
confirmText: opts.confirmText || 'OK',
|
||||
cancelText: opts.cancelText || 'Abbrechen',
|
||||
title: opts.title || t('dialog.confirm_title'),
|
||||
confirmText: opts.confirmText || t('dialog.ok'),
|
||||
cancelText: opts.cancelText || t('dialog.cancel'),
|
||||
type: opts.type || 'info',
|
||||
isConfirm: true
|
||||
});
|
||||
|
||||
+910
@@ -0,0 +1,910 @@
|
||||
/* =============================================
|
||||
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();
|
||||
}
|
||||
};
|
||||
+38
-47
@@ -114,8 +114,8 @@ const ImageRef = {
|
||||
} catch (e) {
|
||||
console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e);
|
||||
HellionDialog.alert(
|
||||
'Bild konnte nicht gespeichert werden. Der Browser-Speicher ist voll.',
|
||||
{ type: 'danger', title: 'Speicherfehler' }
|
||||
t('imageref.storage_error'),
|
||||
{ type: 'danger', title: t('imageref.storage_error.title') }
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -144,8 +144,8 @@ const ImageRef = {
|
||||
|
||||
if (this._images.length >= this.MAX_IMAGES) {
|
||||
await HellionDialog.alert(
|
||||
'Maximal ' + this.MAX_IMAGES + ' Bild-Widgets gleichzeitig. Schliesse eines um ein neues zu oeffnen.',
|
||||
{ type: 'warning', title: 'Limit erreicht' }
|
||||
t('imageref.limit', { max: this.MAX_IMAGES }),
|
||||
{ type: 'warning', title: t('imageref.limit.title') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -172,8 +172,8 @@ const ImageRef = {
|
||||
dataUrl = await this._processFile(file);
|
||||
} catch (err) {
|
||||
await HellionDialog.alert(
|
||||
'Bild konnte nicht geladen werden: ' + err.message,
|
||||
{ type: 'danger', title: 'Bildfehler' }
|
||||
t('imageref.load_error', { error: err.message }),
|
||||
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -206,7 +206,7 @@ const ImageRef = {
|
||||
_createWidget(imageData, dataUrl) {
|
||||
WidgetManager.create('image', {
|
||||
id: imageData.id,
|
||||
title: imageData.label || 'Bild-Referenz',
|
||||
title: imageData.label || t('imageref.title'),
|
||||
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 || 'Bild-Referenz';
|
||||
img.alt = imageData.label || t('imageref.title');
|
||||
wrapper.appendChild(img);
|
||||
|
||||
// Bild ersetzen Button
|
||||
const replaceBtn = document.createElement('button');
|
||||
replaceBtn.className = 'imgref-replace-btn';
|
||||
replaceBtn.type = 'button';
|
||||
replaceBtn.textContent = 'Bild ersetzen';
|
||||
replaceBtn.textContent = t('imageref.replace');
|
||||
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(
|
||||
'Bild konnte nicht geladen werden: ' + err.message,
|
||||
{ type: 'danger', title: 'Bildfehler' }
|
||||
t('imageref.load_error', { error: err.message }),
|
||||
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -283,7 +283,7 @@ const ImageRef = {
|
||||
const label = document.createElement('input');
|
||||
label.className = 'imgref-label';
|
||||
label.type = 'text';
|
||||
label.placeholder = 'Beschriftung (optional)';
|
||||
label.placeholder = t('imageref.label_placeholder');
|
||||
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-text');
|
||||
if (titleEl) titleEl.textContent = text || 'Bild-Referenz';
|
||||
entry.state.title = text || 'Bild-Referenz';
|
||||
const titleEl = entry.el.querySelector('.widget-title');
|
||||
if (titleEl) titleEl.textContent = text || t('imageref.title');
|
||||
entry.state.title = text || t('imageref.title');
|
||||
}
|
||||
|
||||
this._debouncedSave();
|
||||
@@ -321,7 +321,7 @@ const ImageRef = {
|
||||
icon.textContent = '\uD83D\uDDBC\uFE0F';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = 'Klicken oder Bild hierher ziehen';
|
||||
text.textContent = t('imageref.dropzone');
|
||||
|
||||
dropzone.append(icon, text);
|
||||
|
||||
@@ -336,8 +336,8 @@ const ImageRef = {
|
||||
await this.save();
|
||||
} catch (err) {
|
||||
await HellionDialog.alert(
|
||||
'Bild konnte nicht geladen werden: ' + err.message,
|
||||
{ type: 'danger', title: 'Bildfehler' }
|
||||
t('imageref.load_error', { error: err.message }),
|
||||
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -363,8 +363,8 @@ const ImageRef = {
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file || !file.type.startsWith('image/')) {
|
||||
await HellionDialog.alert(
|
||||
'Bitte eine Bilddatei verwenden (PNG, JPG, WebP, etc.).',
|
||||
{ type: 'warning', title: 'Kein Bild' }
|
||||
t('imageref.invalid_file'),
|
||||
{ type: 'warning', title: t('imageref.invalid_file.title') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -376,8 +376,8 @@ const ImageRef = {
|
||||
await this.save();
|
||||
} catch (err) {
|
||||
await HellionDialog.alert(
|
||||
'Bild konnte nicht geladen werden: ' + err.message,
|
||||
{ type: 'danger', title: 'Bildfehler' }
|
||||
t('imageref.load_error', { error: err.message }),
|
||||
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -433,7 +433,7 @@ const ImageRef = {
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error('Bild konnte nicht geladen werden'));
|
||||
reject(new Error(t('imageref.load_error', { error: 'unknown' })));
|
||||
};
|
||||
|
||||
img.src = objectUrl;
|
||||
@@ -460,41 +460,32 @@ const ImageRef = {
|
||||
});
|
||||
}
|
||||
|
||||
// Close-Event abfangen
|
||||
// Widget-Lifecycle-Events
|
||||
const self = this;
|
||||
const prevClose = WidgetManager.close;
|
||||
WidgetManager.close = function(id) {
|
||||
prevClose.call(WidgetManager, id);
|
||||
// Pruefen ob es ein Image-Widget ist
|
||||
const isImage = self._images.some(img => img.id === id);
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
const isImage = self._images.some(img => img.id === e.detail.id);
|
||||
if (isImage) {
|
||||
self.onClose(id);
|
||||
self.onClose(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);
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
const isImage = self._images.some(img => img.id === e.detail.id);
|
||||
if (isImage) {
|
||||
await self.save();
|
||||
self.save();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Open-Event abfangen
|
||||
const prevOpen = WidgetManager.openWidget;
|
||||
WidgetManager.openWidget = async function(id) {
|
||||
await prevOpen.call(WidgetManager, id);
|
||||
const imgData = self._images.find(img => img.id === id);
|
||||
WidgetManager.on('widget:open', (e) => {
|
||||
const imgData = self._images.find(img => img.id === e.detail.id);
|
||||
if (imgData) {
|
||||
const body = WidgetManager.getBody(id);
|
||||
const body = WidgetManager.getBody(e.detail.id);
|
||||
if (body && body.children.length === 0) {
|
||||
const dataUrl = self._getSessionImage(id);
|
||||
const dataUrl = self._getSessionImage(e.detail.id);
|
||||
self.renderBody(imgData, body, dataUrl);
|
||||
}
|
||||
await self.save();
|
||||
self.save();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+16
-16
@@ -75,15 +75,15 @@ const Notes = {
|
||||
async create(template) {
|
||||
if (this._notes.length >= this.MAX_NOTES) {
|
||||
await HellionDialog.alert(
|
||||
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_NOTES + ' Notes gleichzeitig haben. Lösche eine bestehende Note um eine neue zu erstellen.',
|
||||
{ type: 'warning', title: 'Limit erreicht' }
|
||||
t('notes.limit_message', { max: this.MAX_NOTES }),
|
||||
{ type: 'warning', title: t('notes.limit_title') }
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const noteData = {
|
||||
id: 'note_' + uid(),
|
||||
title: template === 'checklist' ? 'Checkliste' : 'Note',
|
||||
title: template === 'checklist' ? t('notes.checklist_title') : t('notes.default_title'),
|
||||
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 = 'Notiz schreiben...';
|
||||
textarea.placeholder = t('notes.placeholder');
|
||||
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 = 'Neues Item...';
|
||||
addInput.placeholder = t('notes.checklist_placeholder');
|
||||
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 = done + '/' + total + ' erledigt';
|
||||
const defaultTitle = t('notes.checklist_progress', { done: done, total: total });
|
||||
const titleEl = widgetEntry.el.querySelector('.widget-title');
|
||||
if (titleEl && titleEl.contentEditable !== 'true') {
|
||||
// Nur wenn Titel noch Standard ist
|
||||
if (noteData.title === 'Checkliste' || /^\d+\/\d+ erledigt$/.test(noteData.title)) {
|
||||
if (noteData.title === t('notes.checklist_title') || /^\d+\/\d+\s/.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(
|
||||
'Note endgültig löschen? Das kann nicht rückgängig gemacht werden.',
|
||||
{ type: 'danger', title: 'Note löschen', confirmText: 'Löschen' }
|
||||
t('notes.delete_confirm'),
|
||||
{ type: 'danger', title: t('notes.delete_title'), confirmText: t('notes.delete_button') }
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
@@ -330,7 +330,7 @@ const Notes = {
|
||||
} else {
|
||||
md += noteData.content || '';
|
||||
}
|
||||
md += '\n\n---\n*Exportiert aus Hellion Dashboard*\n';
|
||||
md += '\n\n---\n*' + t('notes.export_footer') + '*\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 = done + '/' + total + ' erledigt';
|
||||
preview.textContent = t('notes.checklist_progress', { done: done, total: total });
|
||||
} else {
|
||||
preview.textContent = (note.content || '').slice(0, 50) || 'Leer';
|
||||
preview.textContent = (note.content || '').slice(0, 50) || t('notes.empty_preview');
|
||||
}
|
||||
|
||||
// Actions
|
||||
@@ -430,7 +430,7 @@ const Notes = {
|
||||
|
||||
const btnExport = document.createElement('button');
|
||||
btnExport.className = 'notebook-slot-btn';
|
||||
btnExport.textContent = 'Export';
|
||||
btnExport.textContent = t('notes.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 = '+ Note erstellen';
|
||||
label.textContent = t('notes.create');
|
||||
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 = '\u270E Freitext';
|
||||
btnText.textContent = t('notes.text_type');
|
||||
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 = '\u2611 Checkliste';
|
||||
btnCheck.textContent = t('notes.checklist_type');
|
||||
btnCheck.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await this.create('checklist');
|
||||
|
||||
+27
-39
@@ -9,52 +9,40 @@ const Onboarding = {
|
||||
slides: [
|
||||
{
|
||||
hero: '\u2B21',
|
||||
title: 'Willkommen bei Hellion Dashboard',
|
||||
text: 'Dein neuer Browser-Startbildschirm. Minimalistisch, schnell und vollst\u00E4ndig lokal \u2014 keine Cloud, kein Account, keine Datensammlung.'
|
||||
titleKey: 'onboarding.s1.title',
|
||||
textKey: 'onboarding.s1.text'
|
||||
},
|
||||
{
|
||||
hero: '\uD83D\uDCCB',
|
||||
title: 'Boards & Bookmarks',
|
||||
features: [
|
||||
'Erstelle Boards mit dem \u201E+ Board\u201C Button oben',
|
||||
'Importiere Browser-Lesezeichen \u00FCber den \u201EImport\u201C Button im Header',
|
||||
'Drag & Drop zum Umsortieren von Boards und Links',
|
||||
'Blur-Modus f\u00FCr private Boards (\uD83D\uDD12 Icon)'
|
||||
]
|
||||
titleKey: 'onboarding.s2.title',
|
||||
featureKeys: ['onboarding.s2.f1', 'onboarding.s2.f2', 'onboarding.s2.f3', 'onboarding.s2.f4']
|
||||
},
|
||||
{
|
||||
hero: '\uD83C\uDFA8',
|
||||
title: '11 handgefertigte Themes',
|
||||
text: 'Klicke auf den \u201ETheme\u201C Button im Header um dein Theme zu w\u00E4hlen. Jedes hat seinen eigenen Stil und Farbpalette.',
|
||||
titleKey: 'onboarding.s3.title',
|
||||
textKey: 'onboarding.s3.text',
|
||||
showThemes: true
|
||||
},
|
||||
{
|
||||
hero: '\uD83E\uDDF0',
|
||||
title: 'Widget-Toolbar',
|
||||
features: [
|
||||
'Die schwebenden Buttons rechts \u00F6ffnen Widgets',
|
||||
'Notes und Checklisten f\u00FCr schnelle Notizen',
|
||||
'Taschenrechner mit History',
|
||||
'Timer/Countdown mit speicherbaren Presets',
|
||||
'Bild-Referenz Widgets (aktivierbar in Settings)',
|
||||
'Notebook-Sidebar zeigt alle Notes auf einen Blick'
|
||||
]
|
||||
titleKey: 'onboarding.s4.title',
|
||||
featureKeys: ['onboarding.s4.f1', 'onboarding.s4.f2', 'onboarding.s4.f3', 'onboarding.s4.f4', 'onboarding.s4.f5', 'onboarding.s4.f6']
|
||||
},
|
||||
{
|
||||
hero: '\uD83D\uDEE1\uFE0F',
|
||||
title: 'Backups nicht vergessen!',
|
||||
text: 'Deine Daten sind lokal im Browser gespeichert. Wenn du Browserdaten l\u00F6schst, gehen sie verloren! Sichere regelm\u00E4\u00DFig \u00FCber Settings \u2192 Data \u2192 Export. Wir erinnern dich alle 7 Tage daran.'
|
||||
titleKey: 'onboarding.s5.title',
|
||||
textKey: 'onboarding.s5.text'
|
||||
},
|
||||
{
|
||||
hero: '\uD83C\uDFAE',
|
||||
title: 'Gaming Starter Board',
|
||||
text: 'Spielst du Games wie Satisfactory, Factorio oder Star Citizen? Ich kann ein Board mit n\u00FCtzlichen Community-Links anlegen.',
|
||||
titleKey: 'onboarding.s6.title',
|
||||
textKey: 'onboarding.s6.text',
|
||||
interactive: 'gaming-board'
|
||||
},
|
||||
{
|
||||
hero: '\uD83D\uDE80',
|
||||
title: 'Bereit!',
|
||||
text: 'Erstelle dein erstes Board mit \u201E+ Board\u201C oder importiere deine Browser-Lesezeichen \u00FCber den Import-Button im Header. Viel Spa\u00DF!'
|
||||
titleKey: 'onboarding.s7.title',
|
||||
textKey: 'onboarding.s7.text'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -87,7 +75,7 @@ const Onboarding = {
|
||||
if (!isLast) {
|
||||
const skip = document.createElement('button');
|
||||
skip.className = 'onboarding-skip';
|
||||
skip.textContent = '\u00DCberspringen';
|
||||
skip.textContent = t('onboarding.skip');
|
||||
skip.addEventListener('click', () => this._finish());
|
||||
modal.appendChild(skip);
|
||||
}
|
||||
@@ -103,22 +91,22 @@ const Onboarding = {
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'onboarding-title';
|
||||
title.textContent = slide.title;
|
||||
title.textContent = t(slide.titleKey);
|
||||
slideEl.appendChild(title);
|
||||
|
||||
if (slide.text) {
|
||||
if (slide.textKey) {
|
||||
const text = document.createElement('div');
|
||||
text.className = 'onboarding-text';
|
||||
text.textContent = slide.text;
|
||||
text.textContent = t(slide.textKey);
|
||||
slideEl.appendChild(text);
|
||||
}
|
||||
|
||||
if (slide.features) {
|
||||
if (slide.featureKeys) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'onboarding-feature-list';
|
||||
slide.features.forEach(f => {
|
||||
slide.featureKeys.forEach(key => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = f;
|
||||
li.textContent = t(key);
|
||||
list.appendChild(li);
|
||||
});
|
||||
slideEl.appendChild(list);
|
||||
@@ -160,7 +148,7 @@ const Onboarding = {
|
||||
if (this.currentSlide > 0) {
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.className = 'btn-secondary';
|
||||
backBtn.textContent = 'Zur\u00FCck';
|
||||
backBtn.textContent = t('onboarding.back');
|
||||
backBtn.addEventListener('click', () => {
|
||||
this.currentSlide--;
|
||||
this._render();
|
||||
@@ -172,7 +160,7 @@ const Onboarding = {
|
||||
// Interaktive Slide: Zwei Buttons statt "Weiter"
|
||||
const noBtn = document.createElement('button');
|
||||
noBtn.className = 'btn-secondary';
|
||||
noBtn.textContent = 'Nein danke';
|
||||
noBtn.textContent = t('onboarding.no');
|
||||
noBtn.addEventListener('click', () => {
|
||||
this.currentSlide++;
|
||||
this._render();
|
||||
@@ -180,7 +168,7 @@ const Onboarding = {
|
||||
|
||||
const yesBtn = document.createElement('button');
|
||||
yesBtn.className = 'btn-primary';
|
||||
yesBtn.textContent = 'Ja, gerne';
|
||||
yesBtn.textContent = t('onboarding.yes');
|
||||
yesBtn.addEventListener('click', async () => {
|
||||
await this._createGamingBoard();
|
||||
this.currentSlide++;
|
||||
@@ -191,13 +179,13 @@ const Onboarding = {
|
||||
} else if (isLast) {
|
||||
const startBtn = document.createElement('button');
|
||||
startBtn.className = 'btn-primary';
|
||||
startBtn.textContent = 'Los geht\u2019s!';
|
||||
startBtn.textContent = t('onboarding.start');
|
||||
startBtn.addEventListener('click', () => this._finish());
|
||||
nav.appendChild(startBtn);
|
||||
} else {
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.className = 'btn-primary';
|
||||
nextBtn.textContent = 'Weiter';
|
||||
nextBtn.textContent = t('onboarding.next');
|
||||
nextBtn.addEventListener('click', () => {
|
||||
this.currentSlide++;
|
||||
this._render();
|
||||
@@ -227,7 +215,7 @@ const Onboarding = {
|
||||
{ id: uid(), title: 'Modrinth (Mods)', url: 'https://modrinth.com', desc: '' },
|
||||
{ id: uid(), title: 'Star Citizen Wiki', url: 'https://starcitizen.tools', desc: '' },
|
||||
{ id: uid(), title: 'UEX Corp (Trading)', url: 'https://uexcorp.space', desc: '' },
|
||||
{ id: uid(), title: 'Hellion TradeCenter', url: 'https://hellion-initiative.online/tradecenter', desc: 'Trade Center f\u00FCr Star Citizen' }
|
||||
{ id: uid(), title: 'Hellion TradeCenter', url: 'https://hellion-initiative.online/tradecenter', desc: t('onboarding.tradecenter_desc') }
|
||||
],
|
||||
blurred: false
|
||||
};
|
||||
|
||||
+39
-5
@@ -23,6 +23,17 @@ 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']);
|
||||
@@ -83,10 +94,16 @@ 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) {
|
||||
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
|
||||
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
||||
} else if (settings.bgUrl) {
|
||||
settings.bgUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +181,10 @@ function bindSettingsEvents() {
|
||||
});
|
||||
document.getElementById('btnApplyBg').addEventListener('click', async () => {
|
||||
const url = document.getElementById('bgUrlInput').value.trim();
|
||||
if (url && !isValidBgUrl(url)) {
|
||||
await HellionDialog.alert(t('settings.bg_invalid_url'), { type: 'danger', title: t('settings.bg_invalid_url.title') });
|
||||
return;
|
||||
}
|
||||
settings.bgUrl = url;
|
||||
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
|
||||
await saveSettings();
|
||||
@@ -179,16 +200,28 @@ function bindSettingsEvents() {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async ev => {
|
||||
if (!isValidBgUrl(ev.target.result)) return;
|
||||
settings.bgUrl = ev.target.result;
|
||||
document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`;
|
||||
await saveSettings();
|
||||
};
|
||||
reader.onerror = () => {
|
||||
HellionDialog.alert('Fehler beim Lesen der Datei. Bitte eine andere Datei wählen.', { type: 'danger', title: 'Dateifehler' });
|
||||
HellionDialog.alert(t('settings.file_read_error'), { type: 'danger', title: t('settings.file_read_error.title') });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Sprach-Einstellung
|
||||
const languageEl = document.getElementById('settingLanguage');
|
||||
if (languageEl) {
|
||||
languageEl.value = settings.language || 'auto';
|
||||
languageEl.addEventListener('change', async (e) => {
|
||||
settings.language = e.target.value;
|
||||
setLanguage(e.target.value);
|
||||
await saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
// Toolbar-Position Setting
|
||||
const toolbarPosEl = document.getElementById('settingToolbarPos');
|
||||
if (toolbarPosEl) {
|
||||
@@ -209,17 +242,18 @@ function bindSettingsEvents() {
|
||||
// Reset All
|
||||
document.getElementById('btnResetAll').addEventListener('click', async () => {
|
||||
const ok = await HellionDialog.confirm(
|
||||
'Wirklich alle Boards und Einstellungen löschen? Das kann nicht rückgängig gemacht werden.',
|
||||
{ type: 'danger', title: 'Alles zurücksetzen', confirmText: 'Alles löschen' }
|
||||
t('settings.reset_confirm'),
|
||||
{ type: 'danger', title: t('settings.reset_confirm.title'), confirmText: t('settings.reset_confirm.button') }
|
||||
);
|
||||
if (!ok) return;
|
||||
boards = [];
|
||||
settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false,
|
||||
hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula',
|
||||
showSearch: true, searchEngine: 'google', toolbarPos: 'right',
|
||||
imageRefEnabled: false };
|
||||
imageRefEnabled: false, language: 'auto' };
|
||||
await saveBoards();
|
||||
await saveSettings();
|
||||
setLanguage('auto');
|
||||
applySettings();
|
||||
renderBoards();
|
||||
closeSettings();
|
||||
|
||||
+2
-10
@@ -17,7 +17,8 @@ let settings = {
|
||||
showSearch: true,
|
||||
searchEngine: 'google',
|
||||
toolbarPos: 'right',
|
||||
imageRefEnabled: false
|
||||
imageRefEnabled: false,
|
||||
language: 'auto'
|
||||
};
|
||||
|
||||
function uid() {
|
||||
@@ -32,15 +33,6 @@ 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 [
|
||||
{
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ const Store = {
|
||||
chrome.storage.local.set({ [key]: value }, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error('Storage-Fehler:', chrome.runtime.lastError.message);
|
||||
HellionDialog.alert('Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.', { type: 'danger', title: 'Speicher voll' });
|
||||
HellionDialog.alert(t('storage.quota_full'), { type: 'danger', title: t('storage.quota_full.title') });
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const Store = {
|
||||
resolve();
|
||||
} catch (e) {
|
||||
console.error('Storage-Fehler:', e.message);
|
||||
HellionDialog.alert('Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.', { type: 'danger', title: 'Speicher voll' });
|
||||
HellionDialog.alert(t('storage.quota_full'), { type: 'danger', title: t('storage.quota_full.title') });
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-38
@@ -82,7 +82,7 @@ const Timer = {
|
||||
|
||||
WidgetManager.create('timer', {
|
||||
id: this.WIDGET_ID,
|
||||
title: 'Timer',
|
||||
title: t('timer.title'),
|
||||
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 = 'Start';
|
||||
btnStart.textContent = t('timer.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 = 'Pause';
|
||||
btnPause.textContent = t('timer.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 = 'Reset';
|
||||
btnReset.textContent = t('timer.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 = 'Presets';
|
||||
title.textContent = t('timer.presets');
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'timer-preset-add';
|
||||
addBtn.type = 'button';
|
||||
addBtn.textContent = '+';
|
||||
addBtn.title = 'Preset speichern';
|
||||
addBtn.title = t('timer.save_preset');
|
||||
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(
|
||||
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_PRESETS + ' Presets speichern.',
|
||||
{ type: 'warning', title: 'Limit erreicht' }
|
||||
t('timer.limit_message', { max: this.MAX_PRESETS }),
|
||||
{ type: 'warning', title: t('timer.limit_title') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -334,8 +334,8 @@ const Timer = {
|
||||
const parsed = this._parseTimeInput(this._inputEl.value);
|
||||
if (parsed === 0) {
|
||||
HellionDialog.alert(
|
||||
'Gib zuerst eine Zeit ein, bevor du ein Preset speicherst.',
|
||||
{ type: 'info', title: 'Keine Zeit' }
|
||||
t('timer.no_time_message'),
|
||||
{ type: 'info', title: t('timer.no_time_title') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -347,13 +347,13 @@ const Timer = {
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.className = 'timer-add-input';
|
||||
nameInput.type = 'text';
|
||||
nameInput.placeholder = 'Name...';
|
||||
nameInput.placeholder = t('timer.preset_name_placeholder');
|
||||
nameInput.maxLength = 20;
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.className = 'timer-add-confirm';
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.textContent = 'OK';
|
||||
confirmBtn.textContent = t('timer.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 === '[!] Timer abgelaufen'
|
||||
document.title = document.title === t('timer.finished_title')
|
||||
? this._originalTitle
|
||||
: '[!] Timer abgelaufen';
|
||||
: t('timer.finished_title');
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
@@ -521,7 +521,7 @@ const Timer = {
|
||||
if (this._blinkIntervalId) {
|
||||
clearInterval(this._blinkIntervalId);
|
||||
this._blinkIntervalId = null;
|
||||
document.title = this._originalTitle || 'Hellion Dashboard';
|
||||
document.title = this._originalTitle || t('timer.default_page_title');
|
||||
}
|
||||
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 ? 'Ton einschalten' : 'Ton ausschalten';
|
||||
this._muteBtn.title = this._muted ? t('timer.unmute') : t('timer.mute');
|
||||
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 ? 'Neustart' : 'Start';
|
||||
this._btnStart.textContent = this._finished ? t('timer.restart') : t('timer.start');
|
||||
}
|
||||
if (this._btnPause) {
|
||||
this._btnPause.disabled = !this._running;
|
||||
@@ -720,32 +720,23 @@ const Timer = {
|
||||
await this.open();
|
||||
}
|
||||
|
||||
// Close-Event abfangen
|
||||
const origClose = WidgetManager.close.bind(WidgetManager);
|
||||
// Widget-Lifecycle-Events
|
||||
const self = this;
|
||||
const prevClose = WidgetManager.close;
|
||||
WidgetManager.close = function(id) {
|
||||
prevClose.call(WidgetManager, id);
|
||||
if (id === self.WIDGET_ID) {
|
||||
WidgetManager.on('widget:close', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self.onClose();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Minimize-Event abfangen
|
||||
const prevMinimize = WidgetManager.minimize;
|
||||
WidgetManager.minimize = async function(id) {
|
||||
await prevMinimize.call(WidgetManager, id);
|
||||
if (id === self.WIDGET_ID) {
|
||||
WidgetManager.on('widget:minimize', (e) => {
|
||||
if (e.detail.id === self.WIDGET_ID) {
|
||||
self._isOpen = false;
|
||||
await self.save();
|
||||
self.save();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Open-Event abfangen
|
||||
const prevOpen = WidgetManager.openWidget;
|
||||
WidgetManager.openWidget = async function(id) {
|
||||
await prevOpen.call(WidgetManager, id);
|
||||
if (id === self.WIDGET_ID) {
|
||||
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) {
|
||||
@@ -753,8 +744,8 @@ const Timer = {
|
||||
}
|
||||
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||
if (entry) self._bindKeyboard(entry.el);
|
||||
await self.save();
|
||||
self.save();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+57
-10
@@ -9,6 +9,27 @@ 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'
|
||||
@@ -20,7 +41,7 @@ const WidgetManager = {
|
||||
const state = {
|
||||
id,
|
||||
type,
|
||||
title: config.title || 'Note',
|
||||
title: config.title || t('notes.default_title'),
|
||||
x: config.x || 120,
|
||||
y: config.y || 80,
|
||||
width: config.width || 280,
|
||||
@@ -31,7 +52,7 @@ const WidgetManager = {
|
||||
const el = this._buildDOM(state);
|
||||
document.body.appendChild(el);
|
||||
|
||||
this._widgets.set(id, { el, type, state });
|
||||
this._widgets.set(id, { el, type, state, _minimizing: false });
|
||||
this._initDrag(el);
|
||||
this._initResize(el);
|
||||
this.bringToFront(id);
|
||||
@@ -75,7 +96,7 @@ const WidgetManager = {
|
||||
title.addEventListener('blur', async () => {
|
||||
title.contentEditable = 'false';
|
||||
const newTitle = title.textContent.trim().slice(0, 20);
|
||||
title.textContent = newTitle || 'Note';
|
||||
title.textContent = newTitle || t('notes.default_title');
|
||||
const entry = this._widgets.get(state.id);
|
||||
if (entry) {
|
||||
entry.state.title = title.textContent;
|
||||
@@ -94,13 +115,13 @@ const WidgetManager = {
|
||||
|
||||
const btnMin = document.createElement('button');
|
||||
btnMin.className = 'widget-btn widget-minimize';
|
||||
btnMin.title = 'Minimieren';
|
||||
btnMin.title = t('widget.minimize');
|
||||
btnMin.textContent = '\u2500';
|
||||
btnMin.addEventListener('click', () => this.minimize(state.id));
|
||||
|
||||
const btnClose = document.createElement('button');
|
||||
btnClose.className = 'widget-btn widget-close';
|
||||
btnClose.title = 'Schließen';
|
||||
btnClose.title = t('widget.close');
|
||||
btnClose.textContent = '\u2715';
|
||||
btnClose.addEventListener('click', () => this.close(state.id));
|
||||
|
||||
@@ -144,22 +165,47 @@ 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)
|
||||
* Widget minimieren (aus DOM verstecken, bleibt im Notebook).
|
||||
* Nutzt transitionend statt setTimeout — _minimizing Flag verhindert Race Condition
|
||||
* mit openWidget(). Fallback-Timer fuer prefers-reduced-motion / fehlende Transition.
|
||||
* @param {string} id
|
||||
*/
|
||||
async minimize(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry.state.open = false;
|
||||
entry._minimizing = true;
|
||||
entry.el.classList.add('widget-minimized');
|
||||
setTimeout(() => {
|
||||
entry.el.style.display = 'none';
|
||||
}, 250);
|
||||
|
||||
const MINIMIZE_FALLBACK_MS = 350;
|
||||
|
||||
function onEnd(e) {
|
||||
if (e.target !== entry.el || e.propertyName !== 'opacity') return;
|
||||
clearTimeout(fallbackTimer);
|
||||
entry.el.removeEventListener('transitionend', onEnd);
|
||||
if (entry._minimizing) {
|
||||
entry.el.style.display = 'none';
|
||||
}
|
||||
entry._minimizing = false;
|
||||
}
|
||||
|
||||
entry.el.addEventListener('transitionend', onEnd);
|
||||
|
||||
const fallbackTimer = setTimeout(() => {
|
||||
entry.el.removeEventListener('transitionend', onEnd);
|
||||
if (entry._minimizing) {
|
||||
entry.el.style.display = 'none';
|
||||
entry._minimizing = false;
|
||||
}
|
||||
}, MINIMIZE_FALLBACK_MS);
|
||||
|
||||
await this.save();
|
||||
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -169,14 +215,15 @@ 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 } }));
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user