Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41cb185947 | |||
| 0236818372 | |||
| 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 |
@@ -25,7 +25,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \
|
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \
|
||||||
manifest.json newtab.html src/js/*.js src/css/ assets/ \
|
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
|
||||||
-x "*.git*" "dist/*" ".github/*" "src/js/opera/*"
|
-x "*.git*" "dist/*" ".github/*" "src/js/opera/*"
|
||||||
|
|
||||||
- name: Create Firefox ZIP (Manifest V3)
|
- name: Create Firefox ZIP (Manifest V3)
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
cp manifest.json manifest.chrome-backup.json
|
cp manifest.json manifest.chrome-backup.json
|
||||||
cp manifest.firefox.json manifest.json
|
cp manifest.firefox.json manifest.json
|
||||||
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \
|
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \
|
||||||
manifest.json newtab.html src/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/*"
|
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*"
|
||||||
mv manifest.chrome-backup.json manifest.json
|
mv manifest.chrome-backup.json manifest.json
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
cp manifest.json manifest.chrome-backup.json
|
cp manifest.json manifest.chrome-backup.json
|
||||||
cp manifest.opera.json manifest.json
|
cp manifest.opera.json manifest.json
|
||||||
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \
|
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"
|
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.opera.json"
|
||||||
mv manifest.chrome-backup.json manifest.json
|
mv manifest.chrome-backup.json manifest.json
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,6 @@ updates.json
|
|||||||
# Persönliche Backup-Dateien (nicht ins Repo)
|
# Persönliche Backup-Dateien (nicht ins Repo)
|
||||||
favorites_*.html
|
favorites_*.html
|
||||||
*_backup*.json
|
*_backup*.json
|
||||||
|
.mcp.json
|
||||||
|
.claude
|
||||||
|
.superpowers/
|
||||||
@@ -6,6 +6,46 @@ 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
|
### v2.0.0 — 22.03.2026
|
||||||
|
|
||||||
#### New Features
|
#### New Features
|
||||||
|
|||||||
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 |
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "__MSG_extName__",
|
"name": "__MSG_extName__",
|
||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"description": "__MSG_extDesc__",
|
"description": "__MSG_extDesc__",
|
||||||
"author": "Hellion Online Media - Florian Wathling",
|
"author": "Hellion Online Media - Florian Wathling",
|
||||||
"homepage_url": "https://hellion-media.de",
|
"homepage_url": "https://hellion-media.de",
|
||||||
@@ -34,6 +34,9 @@
|
|||||||
"matches": ["<all_urls>"]
|
"matches": ["<all_urls>"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||||
|
},
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "assets/icons/icon16.png",
|
"16": "assets/icons/icon16.png",
|
||||||
"48": "assets/icons/icon48.png",
|
"48": "assets/icons/icon48.png",
|
||||||
|
|||||||
+4
-1
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "__MSG_extName__",
|
"name": "__MSG_extName__",
|
||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"description": "__MSG_extDesc__",
|
"description": "__MSG_extDesc__",
|
||||||
"author": "Hellion Online Media - Florian Wathling",
|
"author": "Hellion Online Media - Florian Wathling",
|
||||||
"homepage_url": "https://hellion-media.de",
|
"homepage_url": "https://hellion-media.de",
|
||||||
@@ -19,6 +19,9 @@
|
|||||||
"matches": ["<all_urls>"]
|
"matches": ["<all_urls>"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||||
|
},
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "assets/icons/icon16.png",
|
"16": "assets/icons/icon16.png",
|
||||||
"48": "assets/icons/icon48.png",
|
"48": "assets/icons/icon48.png",
|
||||||
|
|||||||
+4
-1
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "__MSG_extName__",
|
"name": "__MSG_extName__",
|
||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"description": "__MSG_extDesc__",
|
"description": "__MSG_extDesc__",
|
||||||
"author": "Hellion Online Media - Florian Wathling",
|
"author": "Hellion Online Media - Florian Wathling",
|
||||||
"homepage_url": "https://hellion-media.de",
|
"homepage_url": "https://hellion-media.de",
|
||||||
@@ -40,6 +40,9 @@
|
|||||||
"default_title": "Hellion Dashboard"
|
"default_title": "Hellion Dashboard"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||||
|
},
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "assets/icons/icon16.png",
|
"16": "assets/icons/icon16.png",
|
||||||
"48": "assets/icons/icon48.png",
|
"48": "assets/icons/icon48.png",
|
||||||
|
|||||||
+21
-16
@@ -23,23 +23,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
<span data-i18n="header.import">Import</span>
|
<span data-i18n="header.import">Import</span>
|
||||||
</button>
|
</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>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
<span data-i18n="header.board">Board</span>
|
<span data-i18n="header.board">Board</span>
|
||||||
</button>
|
</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>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
<span data-i18n="header.note">Note</span>
|
<span data-i18n="header.note">Note</span>
|
||||||
</button>
|
</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>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 100 20 4 4 0 01-1-7.9 1 1 0 011-.1h1a2 2 0 002-2V7a5 5 0 00-3-4.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="6" r="1.5"/><circle cx="17" cy="10" r="1.5"/><circle cx="9" cy="17" r="1.5"/></svg>
|
||||||
<span data-i18n="header.theme">Darstellung</span>
|
<span data-i18n="header.theme">Darstellung</span>
|
||||||
</button>
|
</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>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||||
<span data-i18n="header.settings">Settings</span>
|
<span data-i18n="header.settings">Settings</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<span id="searchEngineIcon">G</span>
|
<span id="searchEngineIcon">G</span>
|
||||||
</button>
|
</button>
|
||||||
<input type="text" class="search-input" id="searchInput" data-i18n-placeholder="search.placeholder" placeholder="Search the web…" autocomplete="off" />
|
<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">
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
<aside class="notebook-panel" id="notebookPanel">
|
<aside class="notebook-panel" id="notebookPanel">
|
||||||
<div class="notebook-header">
|
<div class="notebook-header">
|
||||||
<span class="notebook-header-title"><span data-i18n="notebook.title">Notebook</span> <span class="notebook-count" id="notebookCount">0 / 5</span></span>
|
<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">✕</button>
|
<button class="btn-close" id="btnCloseNotebook" data-i18n-title="dialog.close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="notebook-slots" id="notebookSlots">
|
<div class="notebook-slots" id="notebookSlots">
|
||||||
<!-- dynamisch via JS -->
|
<!-- dynamisch via JS -->
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
<aside class="settings-panel" id="settingsPanel">
|
<aside class="settings-panel" id="settingsPanel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span data-i18n="settings.title">Einstellungen</span>
|
<span data-i18n="settings.title">Einstellungen</span>
|
||||||
<button class="btn-close" id="btnCloseSettings">✕</button>
|
<button class="btn-close" id="btnCloseSettings" data-i18n-title="dialog.close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
<span class="setting-label" data-i18n="settings.onboarding">Onboarding wiederholen</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>
|
<span class="setting-desc" data-i18n="settings.onboarding.desc">Willkommens-Tour erneut anzeigen</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-small" id="btnRestartOnboarding">Start</button>
|
<button class="btn-small" id="btnRestartOnboarding" data-i18n="settings.onboarding_btn">Start</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
<span class="setting-label" data-i18n="settings.reset">Alles zurücksetzen</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>
|
<span class="setting-desc" data-i18n="settings.reset.desc">Löscht alle Boards, Notes und Einstellungen</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-danger" id="btnResetAll">Reset</button>
|
<button class="btn-danger" id="btnResetAll" data-i18n="settings.reset_btn">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
<div class="panel-footer">
|
<div class="panel-footer">
|
||||||
<div class="about-block">
|
<div class="about-block">
|
||||||
<div class="about-logo" data-i18n="about.title">⬡ HELLION NEWTAB</div>
|
<div class="about-logo" data-i18n="about.title">⬡ HELLION NEWTAB</div>
|
||||||
<div class="about-version">Version 2.0.0 · by Hellion Online Media</div>
|
<div class="about-version">Version 2.1.0 · by Hellion Online Media</div>
|
||||||
|
|
||||||
<div class="about-links">
|
<div class="about-links">
|
||||||
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
|
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
|
||||||
@@ -294,7 +294,7 @@
|
|||||||
<div class="theme-modal" id="themeModal">
|
<div class="theme-modal" id="themeModal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span data-i18n="modal.theme_header">Darstellung</span>
|
<span data-i18n="modal.theme_header">Darstellung</span>
|
||||||
<button class="btn-close" id="btnCloseTheme">✕</button>
|
<button class="btn-close" id="btnCloseTheme" data-i18n-title="dialog.close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-grid">
|
<div class="theme-grid">
|
||||||
<div class="theme-card active" data-value="nebula">
|
<div class="theme-card active" data-value="nebula">
|
||||||
@@ -371,7 +371,7 @@
|
|||||||
<span class="setting-label" data-i18n="settings.bg_upload">Datei hochladen</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>
|
<span class="setting-desc" data-i18n="settings.bg_upload.desc">Lokales Bild als Hintergrund verwenden</span>
|
||||||
</div>
|
</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" />
|
<input type="file" id="bgFileInput" accept="image/*" class="hidden" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,7 +439,7 @@
|
|||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span data-i18n="modal.new_board">New Board</span>
|
<span data-i18n="modal.new_board">New Board</span>
|
||||||
<button class="btn-close" id="btnCancelBoard">✕</button>
|
<button class="btn-close" id="btnCancelBoard" data-i18n-title="dialog.close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="text" class="text-input full-width" id="newBoardName" data-i18n-placeholder="modal.board_name" placeholder="Board name..." maxlength="40" />
|
<input type="text" class="text-input full-width" id="newBoardName" data-i18n-placeholder="modal.board_name" placeholder="Board name..." maxlength="40" />
|
||||||
@@ -455,7 +455,7 @@
|
|||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span data-i18n="modal.new_bookmark">New Bookmark</span>
|
<span data-i18n="modal.new_bookmark">New Bookmark</span>
|
||||||
<button class="btn-close" id="btnCancelBookmark">✕</button>
|
<button class="btn-close" id="btnCancelBookmark" data-i18n-title="dialog.close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="text" class="text-input full-width" id="newBmTitle" data-i18n-placeholder="modal.bm_title" placeholder="Title..." maxlength="60" />
|
<input type="text" class="text-input full-width" id="newBmTitle" data-i18n-placeholder="modal.bm_title" placeholder="Title..." maxlength="60" />
|
||||||
@@ -473,7 +473,7 @@
|
|||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span data-i18n="modal.rename">Rename</span>
|
<span data-i18n="modal.rename">Rename</span>
|
||||||
<button class="btn-close" id="btnCancelRename">✕</button>
|
<button class="btn-close" id="btnCancelRename" data-i18n-title="dialog.close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="text" class="text-input full-width" id="renameInput" data-i18n-placeholder="modal.rename_placeholder" placeholder="New name..." maxlength="60" />
|
<input type="text" class="text-input full-width" id="renameInput" data-i18n-placeholder="modal.rename_placeholder" placeholder="New name..." maxlength="60" />
|
||||||
@@ -507,6 +507,11 @@
|
|||||||
<script src="src/js/widgets.js"></script>
|
<script src="src/js/widgets.js"></script>
|
||||||
<script src="src/js/notes.js"></script>
|
<script src="src/js/notes.js"></script>
|
||||||
<script src="src/js/calculator.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/timer.js"></script>
|
||||||
<script src="src/js/image-ref.js"></script>
|
<script src="src/js/image-ref.js"></script>
|
||||||
<script src="src/js/bookmark-import.js"></script>
|
<script src="src/js/bookmark-import.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
":dependencyDashboard",
|
||||||
|
":semanticCommits",
|
||||||
|
":timezone(Europe/Berlin)",
|
||||||
|
"schedule:weekly"
|
||||||
|
],
|
||||||
|
"labels": ["dependencies", "renovate"],
|
||||||
|
"assignees": ["JonKazama-Hellion"],
|
||||||
|
"prHourlyLimit": 10,
|
||||||
|
"prConcurrentLimit": 20,
|
||||||
|
"rebaseWhen": "behind-base-branch",
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Group all minor and patch updates per ecosystem in one PR",
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "minor and patch updates ({{manager}})"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Major updates always get their own PR with breaking-change label",
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"labels": ["dependencies", "major-update", "breaking-change"],
|
||||||
|
"addLabels": ["needs-review"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "TypeScript type definitions stay grouped with each other",
|
||||||
|
"matchPackagePrefixes": ["@types/"],
|
||||||
|
"groupName": "type definitions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Dev dependencies in their own group",
|
||||||
|
"matchDepTypes": ["devDependencies"],
|
||||||
|
"groupName": "dev dependencies"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Pin GitHub Action versions by SHA for supply-chain hygiene",
|
||||||
|
"matchManagers": ["github-actions"],
|
||||||
|
"pinDigests": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vulnerabilityAlerts": {
|
||||||
|
"labels": ["security", "vulnerability"],
|
||||||
|
"schedule": ["at any time"],
|
||||||
|
"prPriority": 10
|
||||||
|
},
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": ["before 6am on monday"],
|
||||||
|
"commitMessageAction": "Refresh"
|
||||||
|
},
|
||||||
|
"osvVulnerabilityAlerts": true
|
||||||
|
}
|
||||||
+323
-5
@@ -68,6 +68,19 @@
|
|||||||
--board-hover-border: rgba(179,89,255,0.18);
|
--board-hover-border: rgba(179,89,255,0.18);
|
||||||
--toggle-on-bg: rgba(214,92,255,0.22);
|
--toggle-on-bg: rgba(214,92,255,0.22);
|
||||||
--logo-shadow: rgba(179,89,255,0.35);
|
--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);
|
--board-hover-border: rgba(179,89,255,0.18);
|
||||||
--toggle-on-bg: rgba(214,92,255,0.22);
|
--toggle-on-bg: rgba(214,92,255,0.22);
|
||||||
--logo-shadow: rgba(179,89,255,0.35);
|
--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"] .board { border-color: rgba(214,92,255,0.10); }
|
||||||
[data-theme="nebula"] .bm-item:hover { background: rgba(214,92,255,0.05); }
|
[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);
|
--board-hover-border: rgba(212, 189, 138, 0.20);
|
||||||
--toggle-on-bg: rgba(200,168,74,0.22);
|
--toggle-on-bg: rgba(200,168,74,0.22);
|
||||||
--logo-shadow: rgba(212, 189, 138, 0.40);
|
--logo-shadow: rgba(212, 189, 138, 0.40);
|
||||||
|
--bg-solid-fallback: #080c16;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +161,7 @@
|
|||||||
--board-hover-border: rgba(157, 92, 255, 0.22);
|
--board-hover-border: rgba(157, 92, 255, 0.22);
|
||||||
--toggle-on-bg: rgba(224,128,48,0.22);
|
--toggle-on-bg: rgba(224,128,48,0.22);
|
||||||
--logo-shadow: rgba(157, 92, 255, 0.45);
|
--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"] .board { border-color: rgba(157, 92, 255, 0.15); }
|
||||||
[data-theme="event-horizon"] .bm-item:hover { background: rgba(157, 92, 255, 0.08); }
|
[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);
|
--board-hover-border: rgba(46, 184, 184, 0.20);
|
||||||
--toggle-on-bg: rgba(78,207,207,0.22);
|
--toggle-on-bg: rgba(78,207,207,0.22);
|
||||||
--logo-shadow: rgba(46, 184, 184, 0.45);
|
--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"] .board { border-color: rgba(46, 184, 184, 0.12); }
|
||||||
[data-theme="merchantman"] .bm-item:hover { background: rgba(46, 184, 184, 0.06); }
|
[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);
|
--board-hover-border: rgba(125, 179, 255, 0.22);
|
||||||
--toggle-on-bg: rgba(91,159,255,0.22);
|
--toggle-on-bg: rgba(91,159,255,0.22);
|
||||||
--logo-shadow: rgba(125, 179, 255, 0.50);
|
--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"] .logo { font-family: 'Cinzel', serif; letter-spacing: 5px; text-transform: uppercase; }
|
||||||
[data-theme="julia-jin"] .clock { font-family: 'Cinzel', serif; font-weight: 600; }
|
[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);
|
--board-hover-border: rgba(255, 140, 61, 0.22);
|
||||||
--toggle-on-bg: rgba(240,124,48,0.22);
|
--toggle-on-bg: rgba(240,124,48,0.22);
|
||||||
--logo-shadow: rgba(255, 140, 61, 0.45);
|
--logo-shadow: rgba(255, 140, 61, 0.45);
|
||||||
|
--bg-solid-fallback: #0f0a08;
|
||||||
}
|
}
|
||||||
[data-theme="sc-sunset"] .board {
|
[data-theme="sc-sunset"] .board {
|
||||||
border-color: rgba(255, 140, 61, 0.15);
|
border-color: rgba(255, 140, 61, 0.15);
|
||||||
@@ -253,6 +272,7 @@
|
|||||||
--board-hover-border: rgba(50, 255, 106, 0.20);
|
--board-hover-border: rgba(50, 255, 106, 0.20);
|
||||||
--toggle-on-bg: rgba(34,204,68,0.20);
|
--toggle-on-bg: rgba(34,204,68,0.20);
|
||||||
--logo-shadow: rgba(50, 255, 106, 0.40);
|
--logo-shadow: rgba(50, 255, 106, 0.40);
|
||||||
|
--bg-solid-fallback: #050805;
|
||||||
--danger: #ff4d4d;
|
--danger: #ff4d4d;
|
||||||
}
|
}
|
||||||
[data-theme="hellion-hud"] .board {
|
[data-theme="hellion-hud"] .board {
|
||||||
@@ -287,6 +307,7 @@
|
|||||||
--board-hover-border: rgba(30, 255, 142, 0.25);
|
--board-hover-border: rgba(30, 255, 142, 0.25);
|
||||||
--toggle-on-bg: rgba(0,232,122,0.18);
|
--toggle-on-bg: rgba(0,232,122,0.18);
|
||||||
--logo-shadow: rgba(30, 255, 142, 0.60);
|
--logo-shadow: rgba(30, 255, 142, 0.60);
|
||||||
|
--bg-solid-fallback: #040705;
|
||||||
}
|
}
|
||||||
[data-theme="hellion-energy"] .board {
|
[data-theme="hellion-energy"] .board {
|
||||||
border-color: rgba(30, 255, 142, 0.15);
|
border-color: rgba(30, 255, 142, 0.15);
|
||||||
@@ -322,6 +343,7 @@
|
|||||||
--board-hover-border: rgba(0, 180, 216, 0.25);
|
--board-hover-border: rgba(0, 180, 216, 0.25);
|
||||||
--toggle-on-bg: rgba(0, 180, 216, 0.20);
|
--toggle-on-bg: rgba(0, 180, 216, 0.20);
|
||||||
--logo-shadow: rgba(0, 180, 216, 0.40);
|
--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"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 3px; text-transform: uppercase; }
|
||||||
[data-theme="satisfactory"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); }
|
[data-theme="satisfactory"] .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);
|
--board-hover-border: rgba(46, 196, 160, 0.22);
|
||||||
--toggle-on-bg: rgba(46, 196, 160, 0.18);
|
--toggle-on-bg: rgba(46, 196, 160, 0.18);
|
||||||
--logo-shadow: rgba(46, 196, 160, 0.50);
|
--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"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 500; letter-spacing: 6px; text-transform: uppercase; }
|
||||||
[data-theme="avorion"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 400; color: var(--accent); }
|
[data-theme="avorion"] .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);
|
--board-hover-border: rgba(94, 194, 255, 0.25);
|
||||||
--toggle-on-bg: rgba(94, 194, 255, 0.20);
|
--toggle-on-bg: rgba(94, 194, 255, 0.20);
|
||||||
--logo-shadow: rgba(94, 194, 255, 0.45);
|
--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"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 4px; }
|
||||||
[data-theme="hellion-stealth"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); }
|
[data-theme="hellion-stealth"] .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; }
|
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-local {
|
||||||
.bm-favicon-fallback {
|
width: 16px; height: 16px; flex-shrink: 0;
|
||||||
width: 14px; height: 14px; flex-shrink: 0;
|
border-radius: 3px;
|
||||||
background: var(--accent-dim); border-radius: 2px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
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-text { flex: 1; min-width: 0; }
|
||||||
.bm-title { font-size: 12px; font-weight: 400; color: var(--text-primary); line-height: 1.3; }
|
.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;
|
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
|
TIMER WIDGET
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
+2
-2
@@ -105,7 +105,7 @@ async function checkBackupReminder() {
|
|||||||
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
|
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
|
||||||
const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : [];
|
const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : [];
|
||||||
const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : [];
|
const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : [];
|
||||||
const data = { version: '2.0.0', 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 blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -132,7 +132,7 @@ function startClock() {
|
|||||||
`${t(DAY_KEYS[now.getDay()])}, ${String(now.getDate()).padStart(2,'0')}. ${t(MONTH_KEYS[now.getMonth()])}`;
|
`${t(DAY_KEYS[now.getDay()])}, ${String(now.getDate()).padStart(2,'0')}. ${t(MONTH_KEYS[now.getMonth()])}`;
|
||||||
}
|
}
|
||||||
tick();
|
tick();
|
||||||
setInterval(tick, 1000);
|
const clockInterval = setInterval(tick, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- GLOBALE EVENTS (Header-Buttons, Modals, Import) ----
|
// ---- GLOBALE EVENTS (Header-Buttons, Modals, Import) ----
|
||||||
|
|||||||
+5
-14
@@ -215,19 +215,11 @@ function createBmEl(bm) {
|
|||||||
li.dataset.bmUrl = bm.url;
|
li.dataset.bmUrl = bm.url;
|
||||||
li.draggable = true;
|
li.draggable = true;
|
||||||
|
|
||||||
const favicon = document.createElement('img');
|
const favicon = document.createElement('div');
|
||||||
favicon.className = 'bm-favicon';
|
favicon.className = 'bm-favicon-local';
|
||||||
favicon.width = 14;
|
favicon.textContent = bm.title.charAt(0).toUpperCase();
|
||||||
favicon.height = 14;
|
const hue = (bm.title.charCodeAt(0) * 137) % 360;
|
||||||
favicon.src = getFaviconUrl(bm.url);
|
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
|
||||||
favicon.addEventListener('error', function() {
|
|
||||||
this.classList.add('hidden');
|
|
||||||
this.nextElementSibling.classList.remove('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
const fallback = document.createElement('div');
|
|
||||||
fallback.className = 'bm-favicon-fallback hidden';
|
|
||||||
fallback.textContent = bm.title.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
const textDiv = document.createElement('div');
|
const textDiv = document.createElement('div');
|
||||||
textDiv.className = 'bm-text';
|
textDiv.className = 'bm-text';
|
||||||
@@ -247,7 +239,6 @@ function createBmEl(bm) {
|
|||||||
deleteBtn.textContent = '✕';
|
deleteBtn.textContent = '✕';
|
||||||
|
|
||||||
li.appendChild(favicon);
|
li.appendChild(favicon);
|
||||||
li.appendChild(fallback);
|
|
||||||
li.appendChild(textDiv);
|
li.appendChild(textDiv);
|
||||||
li.appendChild(deleteBtn);
|
li.appendChild(deleteBtn);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
+258
-58
@@ -17,6 +17,22 @@ const Calculator = {
|
|||||||
_displayExprEl: null,
|
_displayExprEl: null,
|
||||||
_displayResultEl: null,
|
_displayResultEl: null,
|
||||||
_keydownHandler: 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 ----
|
// ---- STORAGE ----
|
||||||
|
|
||||||
@@ -27,6 +43,9 @@ const Calculator = {
|
|||||||
const data = await Store.get(this.STORAGE_KEY);
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
if (data && data.calculator) {
|
if (data && data.calculator) {
|
||||||
this._history = Array.isArray(data.calculator.history) ? data.calculator.history : [];
|
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() {
|
async save() {
|
||||||
const data = await Store.get(this.STORAGE_KEY) || {};
|
const data = await Store.get(this.STORAGE_KEY) || {};
|
||||||
const notesState = Array.isArray(data.notes) ? data.notes : [];
|
|
||||||
|
|
||||||
// Widget-Position aus WidgetManager holen
|
// Widget-Position aus WidgetManager holen
|
||||||
const widgetState = WidgetManager.getState(this.WIDGET_ID);
|
const widgetState = WidgetManager.getState(this.WIDGET_ID);
|
||||||
const calcData = {
|
if (!data.calculator) data.calculator = {};
|
||||||
x: widgetState ? widgetState.x : 400,
|
data.calculator.x = widgetState ? widgetState.x : 400;
|
||||||
y: widgetState ? widgetState.y : 120,
|
data.calculator.y = widgetState ? widgetState.y : 120;
|
||||||
width: widgetState ? widgetState.width : 280,
|
data.calculator.width = widgetState ? widgetState.width : 280;
|
||||||
height: widgetState ? widgetState.height : 400,
|
data.calculator.height = widgetState ? widgetState.height : 400;
|
||||||
open: this._isOpen,
|
data.calculator.open = this._isOpen;
|
||||||
history: this._history.slice(0, this.MAX_HISTORY)
|
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 ----
|
// ---- WIDGET LIFECYCLE ----
|
||||||
@@ -113,8 +131,13 @@ const Calculator = {
|
|||||||
* Wird aufgerufen wenn Widget geschlossen wird
|
* Wird aufgerufen wenn Widget geschlossen wird
|
||||||
*/
|
*/
|
||||||
async onClose() {
|
async onClose() {
|
||||||
|
// Aktiven Modus aufräumen
|
||||||
|
const mode = this._modes.get(this._activeMode);
|
||||||
|
if (mode && mode.destroy) mode.destroy();
|
||||||
|
|
||||||
this._isOpen = false;
|
this._isOpen = false;
|
||||||
this._unbindKeyboard();
|
this._unbindKeyboard();
|
||||||
|
this._tabBarEl = null;
|
||||||
this._displayExprEl = null;
|
this._displayExprEl = null;
|
||||||
this._displayResultEl = null;
|
this._displayResultEl = null;
|
||||||
await this.save();
|
await this.save();
|
||||||
@@ -123,14 +146,136 @@ const Calculator = {
|
|||||||
// ---- UI RENDERING ----
|
// ---- UI RENDERING ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculator-Body rendern (in Widget-Body einfuegen)
|
* Calculator-Body rendern: Tab-Bar + aktiver Modus
|
||||||
* @param {HTMLElement} bodyEl
|
* @param {HTMLElement} bodyEl
|
||||||
*/
|
*/
|
||||||
renderBody(bodyEl) {
|
renderBody(bodyEl) {
|
||||||
bodyEl.textContent = '';
|
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.padding = '8px';
|
||||||
bodyEl.style.display = 'flex';
|
bodyEl.style.display = 'flex';
|
||||||
bodyEl.style.flexDirection = 'column';
|
bodyEl.style.flexDirection = 'column';
|
||||||
|
bodyEl.style.flex = '1';
|
||||||
|
bodyEl.style.overflow = 'hidden';
|
||||||
|
|
||||||
// Display
|
// Display
|
||||||
const display = document.createElement('div');
|
const display = document.createElement('div');
|
||||||
@@ -297,7 +442,8 @@ const Calculator = {
|
|||||||
case '+':
|
case '+':
|
||||||
case '-':
|
case '-':
|
||||||
case '*':
|
case '*':
|
||||||
case '/': {
|
case '/':
|
||||||
|
case '^': {
|
||||||
// Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen
|
// Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen
|
||||||
if (this._lastResult && this._currentExpr === '') {
|
if (this._lastResult && this._currentExpr === '') {
|
||||||
this._currentExpr = this._lastResult;
|
this._currentExpr = this._lastResult;
|
||||||
@@ -305,7 +451,7 @@ const Calculator = {
|
|||||||
}
|
}
|
||||||
// Doppelte Operatoren verhindern (letzten ersetzen)
|
// Doppelte Operatoren verhindern (letzten ersetzen)
|
||||||
const last = this._currentExpr.slice(-1);
|
const last = this._currentExpr.slice(-1);
|
||||||
if (/[+\-*/%]/.test(last)) {
|
if (/[+\-*/%^]/.test(last)) {
|
||||||
this._currentExpr = this._currentExpr.slice(0, -1) + key;
|
this._currentExpr = this._currentExpr.slice(0, -1) + key;
|
||||||
} else {
|
} else {
|
||||||
this._currentExpr += key;
|
this._currentExpr += key;
|
||||||
@@ -315,7 +461,7 @@ const Calculator = {
|
|||||||
|
|
||||||
case '.': {
|
case '.': {
|
||||||
// Doppelten Dezimalpunkt im letzten Zahlenblock verhindern
|
// Doppelten Dezimalpunkt im letzten Zahlenblock verhindern
|
||||||
const parts = this._currentExpr.split(/[+\-*/%()]/);
|
const parts = this._currentExpr.split(/[+\-*/%()^]/);
|
||||||
const lastPart = parts[parts.length - 1];
|
const lastPart = parts[parts.length - 1];
|
||||||
if (lastPart && lastPart.includes('.')) break;
|
if (lastPart && lastPart.includes('.')) break;
|
||||||
this._currentExpr += key;
|
this._currentExpr += key;
|
||||||
@@ -381,7 +527,7 @@ const Calculator = {
|
|||||||
_evaluate(expr) {
|
_evaluate(expr) {
|
||||||
try {
|
try {
|
||||||
// Nur erlaubte Zeichen
|
// Nur erlaubte Zeichen
|
||||||
const sanitized = expr.replace(/[^0-9+\-*/.%()]/g, '');
|
const sanitized = expr.replace(/[^0-9+\-*/.%()^a-z]/g, '');
|
||||||
if (!sanitized) return null;
|
if (!sanitized) return null;
|
||||||
|
|
||||||
const tokens = this._tokenize(sanitized);
|
const tokens = this._tokenize(sanitized);
|
||||||
@@ -405,6 +551,13 @@ const Calculator = {
|
|||||||
while (i < expr.length) {
|
while (i < expr.length) {
|
||||||
const ch = expr[i];
|
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)
|
// Zahl (inkl. Dezimal)
|
||||||
if (/[0-9.]/.test(ch)) {
|
if (/[0-9.]/.test(ch)) {
|
||||||
let num = '';
|
let num = '';
|
||||||
@@ -443,6 +596,13 @@ const Calculator = {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Potenz-Operator
|
||||||
|
if (ch === '^') {
|
||||||
|
tokens.push({ type: 'op', value: '^' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Klammern
|
// Klammern
|
||||||
if (ch === '(' || ch === ')') {
|
if (ch === '(' || ch === ')') {
|
||||||
tokens.push({ type: 'paren', value: ch });
|
tokens.push({ type: 'paren', value: ch });
|
||||||
@@ -450,6 +610,11 @@ const Calculator = {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unbekannte Buchstaben
|
||||||
|
if (/[a-z]/.test(ch)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Unbekanntes Zeichen
|
// Unbekanntes Zeichen
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -459,6 +624,7 @@ const Calculator = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Rekursiver Descent Parser mit Operator-Precedence
|
* Rekursiver Descent Parser mit Operator-Precedence
|
||||||
|
* Hierarchie: parseExpr (+/-) → parseTerm (*\/%) → parsePower (^) → parseFactor
|
||||||
* @param {Array} tokens
|
* @param {Array} tokens
|
||||||
* @returns {number|null}
|
* @returns {number|null}
|
||||||
*/
|
*/
|
||||||
@@ -468,36 +634,32 @@ const Calculator = {
|
|||||||
function peek() { return tokens[pos]; }
|
function peek() { return tokens[pos]; }
|
||||||
function consume() { return tokens[pos++]; }
|
function consume() { return tokens[pos++]; }
|
||||||
|
|
||||||
// Expression: Term (('+' | '-') Term)*
|
|
||||||
function parseExpr() {
|
function parseExpr() {
|
||||||
let left = parseTerm();
|
let left = parseTerm();
|
||||||
if (left === null) return null;
|
if (left === null) return null;
|
||||||
|
|
||||||
while (pos < tokens.length) {
|
while (pos < tokens.length) {
|
||||||
const t = peek();
|
const tk = peek();
|
||||||
if (!t || t.type !== 'op' || (t.value !== '+' && t.value !== '-')) break;
|
if (!tk || tk.type !== 'op' || (tk.value !== '+' && tk.value !== '-')) break;
|
||||||
consume();
|
consume();
|
||||||
const right = parseTerm();
|
const right = parseTerm();
|
||||||
if (right === null) return null;
|
if (right === null) return null;
|
||||||
left = t.value === '+' ? left + right : left - right;
|
left = tk.value === '+' ? left + right : left - right;
|
||||||
}
|
}
|
||||||
return left;
|
return left;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Term: Factor (('*' | '/' | '%') Factor)*
|
|
||||||
function parseTerm() {
|
function parseTerm() {
|
||||||
let left = parseFactor();
|
let left = parsePower();
|
||||||
if (left === null) return null;
|
if (left === null) return null;
|
||||||
|
|
||||||
while (pos < tokens.length) {
|
while (pos < tokens.length) {
|
||||||
const t = peek();
|
const tk = peek();
|
||||||
if (!t || t.type !== 'op' || (t.value !== '*' && t.value !== '/' && t.value !== '%')) break;
|
if (!tk || tk.type !== 'op' || (tk.value !== '*' && tk.value !== '/' && tk.value !== '%')) break;
|
||||||
consume();
|
consume();
|
||||||
const right = parseFactor();
|
const right = parsePower();
|
||||||
if (right === null) return null;
|
if (right === null) return null;
|
||||||
if (t.value === '*') {
|
if (tk.value === '*') {
|
||||||
left = left * right;
|
left = left * right;
|
||||||
} else if (t.value === '/') {
|
} else if (tk.value === '/') {
|
||||||
if (right === 0) return null;
|
if (right === 0) return null;
|
||||||
left = left / right;
|
left = left / right;
|
||||||
} else {
|
} else {
|
||||||
@@ -507,17 +669,51 @@ const Calculator = {
|
|||||||
return left;
|
return left;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Factor: Number | '(' Expression ')'
|
// Power: Factor ('^' Power)? — rechts-assoziativ via Rekursion
|
||||||
function parseFactor() {
|
function parsePower() {
|
||||||
const t = peek();
|
let base = parseFactor();
|
||||||
if (!t) return null;
|
if (base === null) return null;
|
||||||
|
const tk = peek();
|
||||||
if (t.type === 'number') {
|
if (tk && tk.type === 'op' && tk.value === '^') {
|
||||||
consume();
|
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();
|
consume();
|
||||||
const val = parseExpr();
|
const val = parseExpr();
|
||||||
if (val === null) return null;
|
if (val === null) return null;
|
||||||
@@ -562,7 +758,8 @@ const Calculator = {
|
|||||||
_formatExpression(expr) {
|
_formatExpression(expr) {
|
||||||
return expr
|
return expr
|
||||||
.replace(/\*/g, '\u00D7')
|
.replace(/\*/g, '\u00D7')
|
||||||
.replace(/\//g, '\u00F7');
|
.replace(/\//g, '\u00F7')
|
||||||
|
.replace(/sqrt\(/g, '\u221A(');
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- DISPLAY ----
|
// ---- DISPLAY ----
|
||||||
@@ -683,47 +880,50 @@ const Calculator = {
|
|||||||
async init() {
|
async init() {
|
||||||
await this.load();
|
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
|
// Wenn Calculator beim letzten Mal offen war, wiederherstellen
|
||||||
const data = await Store.get(this.STORAGE_KEY);
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
if (data && data.calculator && data.calculator.open) {
|
if (data && data.calculator && data.calculator.open) {
|
||||||
await this.open();
|
await this.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close-Event abfangen: WidgetManager.close() ueberschreiben
|
// Widget-Lifecycle-Events
|
||||||
const origClose = WidgetManager.close.bind(WidgetManager);
|
|
||||||
const self = this;
|
const self = this;
|
||||||
WidgetManager.close = function(id) {
|
WidgetManager.on('widget:close', (e) => {
|
||||||
origClose(id);
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
if (id === self.WIDGET_ID) {
|
|
||||||
self.onClose();
|
self.onClose();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
// Minimize-Event abfangen
|
WidgetManager.on('widget:minimize', (e) => {
|
||||||
const origMinimize = WidgetManager.minimize.bind(WidgetManager);
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
WidgetManager.minimize = async function(id) {
|
|
||||||
await origMinimize(id);
|
|
||||||
if (id === self.WIDGET_ID) {
|
|
||||||
self._isOpen = false;
|
self._isOpen = false;
|
||||||
await self.save();
|
self.save();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
// Open-Event abfangen
|
WidgetManager.on('widget:open', (e) => {
|
||||||
const origOpen = WidgetManager.openWidget.bind(WidgetManager);
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
WidgetManager.openWidget = async function(id) {
|
|
||||||
await origOpen(id);
|
|
||||||
if (id === self.WIDGET_ID) {
|
|
||||||
self._isOpen = true;
|
self._isOpen = true;
|
||||||
// Body neu rendern (war durch minimize entfernt)
|
|
||||||
const body = WidgetManager.getBody(self.WIDGET_ID);
|
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||||
if (body && body.children.length === 0) {
|
if (body && body.children.length === 0) {
|
||||||
self.renderBody(body);
|
self.renderBody(body);
|
||||||
}
|
}
|
||||||
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||||
if (entry) self._bindKeyboard(entry.el);
|
if (entry) self._bindKeyboard(entry.el);
|
||||||
await self.save();
|
self.save();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+50
-21
@@ -9,11 +9,26 @@ function initDataButtons() {
|
|||||||
const jsonInput = document.getElementById('jsonImportInput');
|
const jsonInput = document.getElementById('jsonImportInput');
|
||||||
if (!btnExport || !btnImport) return;
|
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)
|
// Export (inkl. Notes)
|
||||||
btnExport.addEventListener('click', async () => {
|
btnExport.addEventListener('click', async () => {
|
||||||
const widgetData = await Store.get('widgetStates');
|
const widgetData = await Store.get('widgetStates');
|
||||||
const data = {
|
const data = {
|
||||||
version: '2.0.0',
|
version: '2.1.0',
|
||||||
exported: new Date().toISOString(),
|
exported: new Date().toISOString(),
|
||||||
boards,
|
boards,
|
||||||
settings,
|
settings,
|
||||||
@@ -38,18 +53,21 @@ function initDataButtons() {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(await file.text());
|
const data = JSON.parse(await file.text());
|
||||||
if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format'));
|
if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format'));
|
||||||
const validBoards = data.boards.filter(b => {
|
const validBoards = data.boards
|
||||||
if (!b || typeof b.title !== 'string' || !Array.isArray(b.bookmarks)) return false;
|
.filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
|
||||||
b.id = b.id || uid();
|
.map(b => ({
|
||||||
b.blurred = !!b.blurred;
|
id: b.id || uid(),
|
||||||
b.bookmarks = b.bookmarks.filter(bm => {
|
title: String(b.title).slice(0, 100),
|
||||||
if (!bm || typeof bm.title !== 'string' || typeof bm.url !== 'string') return false;
|
blurred: !!b.blurred,
|
||||||
bm.id = bm.id || uid();
|
bookmarks: b.bookmarks
|
||||||
bm.desc = bm.desc || '';
|
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
|
||||||
return true;
|
.map(bm => ({
|
||||||
});
|
id: bm.id || uid(),
|
||||||
return true;
|
title: String(bm.title).slice(0, 200),
|
||||||
});
|
url: bm.url,
|
||||||
|
desc: String(bm.desc || '').slice(0, 500)
|
||||||
|
}))
|
||||||
|
}));
|
||||||
if (validBoards.length === 0) throw new Error(t('data.no_boards'));
|
if (validBoards.length === 0) throw new Error(t('data.no_boards'));
|
||||||
const ok = await HellionDialog.confirm(
|
const ok = await HellionDialog.confirm(
|
||||||
t('data.import_confirm', { count: validBoards.length }),
|
t('data.import_confirm', { count: validBoards.length }),
|
||||||
@@ -65,18 +83,26 @@ function initDataButtons() {
|
|||||||
const existingWidgets = await Store.get('widgetStates') || {};
|
const existingWidgets = await Store.get('widgetStates') || {};
|
||||||
if (Array.isArray(data.notes) && data.notes.length > 0) {
|
if (Array.isArray(data.notes) && data.notes.length > 0) {
|
||||||
const existingNotes = Array.isArray(existingWidgets.notes) ? existingWidgets.notes : [];
|
const existingNotes = Array.isArray(existingWidgets.notes) ? existingWidgets.notes : [];
|
||||||
const importNotes = data.notes.filter(n => {
|
const importNotes = data.notes
|
||||||
if (!n || !n.id || !n.template) return false;
|
.filter(n => n && n.id && n.template)
|
||||||
n.checklistItems = Array.isArray(n.checklistItems) ? n.checklistItems : [];
|
.map(n => ({
|
||||||
return true;
|
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
|
// Limit beachten
|
||||||
const spaceLeft = Notes.MAX_NOTES - existingNotes.length;
|
const spaceLeft = Notes.MAX_NOTES - existingNotes.length;
|
||||||
const toImport = importNotes.slice(0, spaceLeft);
|
const toImport = importNotes.slice(0, spaceLeft);
|
||||||
if (toImport.length > 0) {
|
if (toImport.length > 0) {
|
||||||
const merged = [...existingNotes, ...toImport];
|
const merged = [...existingNotes, ...toImport];
|
||||||
existingWidgets.notes = merged;
|
existingWidgets.notes = merged;
|
||||||
Notes._notes = merged;
|
|
||||||
notesImported = toImport.length;
|
notesImported = toImport.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +116,6 @@ function initDataButtons() {
|
|||||||
existingWidgets.calculator = { x: 400, y: 120, width: 280, height: 400, open: false, history: [] };
|
existingWidgets.calculator = { x: 400, y: 120, width: 280, height: 400, open: false, history: [] };
|
||||||
}
|
}
|
||||||
existingWidgets.calculator.history = calcHistory.slice(0, Calculator.MAX_HISTORY);
|
existingWidgets.calculator.history = calcHistory.slice(0, Calculator.MAX_HISTORY);
|
||||||
Calculator._history = existingWidgets.calculator.history;
|
|
||||||
calcImported = true;
|
calcImported = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +129,6 @@ function initDataButtons() {
|
|||||||
existingWidgets.timer = { x: 600, y: 80, width: 260, height: 360, open: false, presets: [] };
|
existingWidgets.timer = { x: 600, y: 80, width: 260, height: 360, open: false, presets: [] };
|
||||||
}
|
}
|
||||||
existingWidgets.timer.presets = validPresets.slice(0, Timer.MAX_PRESETS);
|
existingWidgets.timer.presets = validPresets.slice(0, Timer.MAX_PRESETS);
|
||||||
Timer._presets = existingWidgets.timer.presets;
|
|
||||||
timerImported = true;
|
timerImported = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +136,11 @@ function initDataButtons() {
|
|||||||
// Gemeinsam speichern
|
// Gemeinsam speichern
|
||||||
await Store.set('widgetStates', existingWidgets);
|
await Store.set('widgetStates', existingWidgets);
|
||||||
|
|
||||||
|
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
|
||||||
|
if (notesImported > 0) await Notes.init();
|
||||||
|
if (calcImported) await Calculator.load();
|
||||||
|
if (timerImported) await Timer.load();
|
||||||
|
|
||||||
const noteMsg = notesImported > 0 ? t('data.notes_suffix', { count: notesImported }) : '';
|
const noteMsg = notesImported > 0 ? t('data.notes_suffix', { count: notesImported }) : '';
|
||||||
const calcMsg = calcImported ? t('data.calc_suffix') : '';
|
const calcMsg = calcImported ? t('data.calc_suffix') : '';
|
||||||
const timerMsg = timerImported ? t('data.timer_suffix') : '';
|
const timerMsg = timerImported ? t('data.timer_suffix') : '';
|
||||||
|
|||||||
+245
-1
@@ -10,6 +10,7 @@ const STRINGS = {
|
|||||||
'dialog.ok': 'OK',
|
'dialog.ok': 'OK',
|
||||||
'dialog.confirm_title': 'Bestätigung',
|
'dialog.confirm_title': 'Bestätigung',
|
||||||
'dialog.cancel': 'Abbrechen',
|
'dialog.cancel': 'Abbrechen',
|
||||||
|
'dialog.close': 'Schließen',
|
||||||
|
|
||||||
// Boards
|
// Boards
|
||||||
'boards.empty_state_pre': 'Noch keine Boards. Klicke auf ',
|
'boards.empty_state_pre': 'Noch keine Boards. Klicke auf ',
|
||||||
@@ -56,6 +57,7 @@ const STRINGS = {
|
|||||||
'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.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.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.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.title': 'Bereit!',
|
||||||
'onboarding.s7.text': 'Erstelle dein erstes Board mit „+ Board" oder importiere deine Browser-Lesezeichen über den Import-Button im Header. Viel Spaß!',
|
'onboarding.s7.text': 'Erstelle dein erstes Board mit „+ Board" oder importiere deine Browser-Lesezeichen über den Import-Button im Header. Viel Spaß!',
|
||||||
|
|
||||||
@@ -81,6 +83,110 @@ const STRINGS = {
|
|||||||
'calculator.title': 'Taschenrechner',
|
'calculator.title': 'Taschenrechner',
|
||||||
'calculator.history': 'History',
|
'calculator.history': 'History',
|
||||||
'calculator.error': 'Fehler',
|
'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
|
||||||
'timer.title': 'Timer',
|
'timer.title': 'Timer',
|
||||||
@@ -202,6 +308,13 @@ const STRINGS = {
|
|||||||
'header.theme': 'Darstellung',
|
'header.theme': 'Darstellung',
|
||||||
'header.settings': 'Einstellungen',
|
'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-Panel Überschrift
|
||||||
'settings.title': 'Einstellungen',
|
'settings.title': 'Einstellungen',
|
||||||
|
|
||||||
@@ -255,6 +368,13 @@ const STRINGS = {
|
|||||||
'settings.bg_upload.desc': 'Lokales Bild als Hintergrund verwenden',
|
'settings.bg_upload.desc': 'Lokales Bild als Hintergrund verwenden',
|
||||||
'settings.search_engine_toggle': 'Suchmaschine wechseln',
|
'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
|
// Modals
|
||||||
'modal.new_board': 'Neues Board',
|
'modal.new_board': 'Neues Board',
|
||||||
'modal.board_name': 'Board-Name...',
|
'modal.board_name': 'Board-Name...',
|
||||||
@@ -285,6 +405,7 @@ const STRINGS = {
|
|||||||
|
|
||||||
// Suche
|
// Suche
|
||||||
'search.placeholder': 'Im Web suchen…',
|
'search.placeholder': 'Im Web suchen…',
|
||||||
|
'search.submit_title': 'Suchen',
|
||||||
|
|
||||||
// Widget-Toolbar Tooltips
|
// Widget-Toolbar Tooltips
|
||||||
'toolbar.note': 'Note erstellen',
|
'toolbar.note': 'Note erstellen',
|
||||||
@@ -301,6 +422,7 @@ const STRINGS = {
|
|||||||
'dialog.ok': 'OK',
|
'dialog.ok': 'OK',
|
||||||
'dialog.confirm_title': 'Confirmation',
|
'dialog.confirm_title': 'Confirmation',
|
||||||
'dialog.cancel': 'Cancel',
|
'dialog.cancel': 'Cancel',
|
||||||
|
'dialog.close': 'Close',
|
||||||
|
|
||||||
// Boards
|
// Boards
|
||||||
'boards.empty_state_pre': 'No boards yet. Click ',
|
'boards.empty_state_pre': 'No boards yet. Click ',
|
||||||
@@ -347,6 +469,7 @@ const STRINGS = {
|
|||||||
'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.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.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.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.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!',
|
'onboarding.s7.text': 'Create your first board with "+ Board" or import your browser bookmarks via the Import button in the header. Have fun!',
|
||||||
|
|
||||||
@@ -372,6 +495,110 @@ const STRINGS = {
|
|||||||
'calculator.title': 'Calculator',
|
'calculator.title': 'Calculator',
|
||||||
'calculator.history': 'History',
|
'calculator.history': 'History',
|
||||||
'calculator.error': 'Error',
|
'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
|
||||||
'timer.title': 'Timer',
|
'timer.title': 'Timer',
|
||||||
@@ -493,6 +720,13 @@ const STRINGS = {
|
|||||||
'header.theme': 'Theme',
|
'header.theme': 'Theme',
|
||||||
'header.settings': 'Settings',
|
'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 panel heading
|
||||||
'settings.title': 'Settings',
|
'settings.title': 'Settings',
|
||||||
|
|
||||||
@@ -546,6 +780,13 @@ const STRINGS = {
|
|||||||
'settings.bg_upload.desc': 'Use a local image as background',
|
'settings.bg_upload.desc': 'Use a local image as background',
|
||||||
'settings.search_engine_toggle': 'Switch search engine',
|
'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
|
// Modals
|
||||||
'modal.new_board': 'New Board',
|
'modal.new_board': 'New Board',
|
||||||
'modal.board_name': 'Board name...',
|
'modal.board_name': 'Board name...',
|
||||||
@@ -576,6 +817,7 @@ const STRINGS = {
|
|||||||
|
|
||||||
// Search
|
// Search
|
||||||
'search.placeholder': 'Search the web…',
|
'search.placeholder': 'Search the web…',
|
||||||
|
'search.submit_title': 'Search',
|
||||||
|
|
||||||
// Widget toolbar tooltips
|
// Widget toolbar tooltips
|
||||||
'toolbar.note': 'Create note',
|
'toolbar.note': 'Create note',
|
||||||
@@ -619,7 +861,9 @@ function applyLanguage() {
|
|||||||
el.placeholder = t(el.dataset.i18nPlaceholder);
|
el.placeholder = t(el.dataset.i18nPlaceholder);
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||||
el.title = t(el.dataset.i18nTitle);
|
const text = t(el.dataset.i18nTitle);
|
||||||
|
el.title = text;
|
||||||
|
el.setAttribute('aria-label', text);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-25
@@ -294,7 +294,7 @@ const ImageRef = {
|
|||||||
// Widget-Titel aktualisieren
|
// Widget-Titel aktualisieren
|
||||||
const entry = WidgetManager._widgets.get(imageData.id);
|
const entry = WidgetManager._widgets.get(imageData.id);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const titleEl = entry.el.querySelector('.widget-title-text');
|
const titleEl = entry.el.querySelector('.widget-title');
|
||||||
if (titleEl) titleEl.textContent = text || t('imageref.title');
|
if (titleEl) titleEl.textContent = text || t('imageref.title');
|
||||||
entry.state.title = text || t('imageref.title');
|
entry.state.title = text || t('imageref.title');
|
||||||
}
|
}
|
||||||
@@ -460,41 +460,32 @@ const ImageRef = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close-Event abfangen
|
// Widget-Lifecycle-Events
|
||||||
const self = this;
|
const self = this;
|
||||||
const prevClose = WidgetManager.close;
|
WidgetManager.on('widget:close', (e) => {
|
||||||
WidgetManager.close = function(id) {
|
const isImage = self._images.some(img => img.id === e.detail.id);
|
||||||
prevClose.call(WidgetManager, id);
|
|
||||||
// Pruefen ob es ein Image-Widget ist
|
|
||||||
const isImage = self._images.some(img => img.id === id);
|
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
self.onClose(id);
|
self.onClose(e.detail.id);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
// Minimize-Event abfangen
|
WidgetManager.on('widget:minimize', (e) => {
|
||||||
const prevMinimize = WidgetManager.minimize;
|
const isImage = self._images.some(img => img.id === e.detail.id);
|
||||||
WidgetManager.minimize = async function(id) {
|
|
||||||
await prevMinimize.call(WidgetManager, id);
|
|
||||||
const isImage = self._images.some(img => img.id === id);
|
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
await self.save();
|
self.save();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
// Open-Event abfangen
|
WidgetManager.on('widget:open', (e) => {
|
||||||
const prevOpen = WidgetManager.openWidget;
|
const imgData = self._images.find(img => img.id === e.detail.id);
|
||||||
WidgetManager.openWidget = async function(id) {
|
|
||||||
await prevOpen.call(WidgetManager, id);
|
|
||||||
const imgData = self._images.find(img => img.id === id);
|
|
||||||
if (imgData) {
|
if (imgData) {
|
||||||
const body = WidgetManager.getBody(id);
|
const body = WidgetManager.getBody(e.detail.id);
|
||||||
if (body && body.children.length === 0) {
|
if (body && body.children.length === 0) {
|
||||||
const dataUrl = self._getSessionImage(id);
|
const dataUrl = self._getSessionImage(e.detail.id);
|
||||||
self.renderBody(imgData, body, dataUrl);
|
self.renderBody(imgData, body, dataUrl);
|
||||||
}
|
}
|
||||||
await self.save();
|
self.save();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ const Onboarding = {
|
|||||||
{ id: uid(), title: 'Modrinth (Mods)', url: 'https://modrinth.com', desc: '' },
|
{ id: uid(), title: 'Modrinth (Mods)', url: 'https://modrinth.com', desc: '' },
|
||||||
{ id: uid(), title: 'Star Citizen Wiki', url: 'https://starcitizen.tools', desc: '' },
|
{ id: uid(), title: 'Star Citizen Wiki', url: 'https://starcitizen.tools', desc: '' },
|
||||||
{ id: uid(), title: 'UEX Corp (Trading)', url: 'https://uexcorp.space', 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
|
blurred: false
|
||||||
};
|
};
|
||||||
|
|||||||
+19
-1
@@ -23,6 +23,17 @@ function closeThemeModal() {
|
|||||||
overlay.classList.remove('active');
|
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 ----
|
// ---- ACCORDION ----
|
||||||
function initAccordion() {
|
function initAccordion() {
|
||||||
const defaultOpen = new Set(['widgets']);
|
const defaultOpen = new Set(['widgets']);
|
||||||
@@ -89,8 +100,10 @@ function applySettings() {
|
|||||||
|
|
||||||
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
|
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
|
||||||
|
|
||||||
if (settings.bgUrl) {
|
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
|
||||||
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
||||||
|
} else if (settings.bgUrl) {
|
||||||
|
settings.bgUrl = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +181,10 @@ function bindSettingsEvents() {
|
|||||||
});
|
});
|
||||||
document.getElementById('btnApplyBg').addEventListener('click', async () => {
|
document.getElementById('btnApplyBg').addEventListener('click', async () => {
|
||||||
const url = document.getElementById('bgUrlInput').value.trim();
|
const url = document.getElementById('bgUrlInput').value.trim();
|
||||||
|
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;
|
settings.bgUrl = url;
|
||||||
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
|
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
@@ -183,6 +200,7 @@ function bindSettingsEvents() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async ev => {
|
reader.onload = async ev => {
|
||||||
|
if (!isValidBgUrl(ev.target.result)) return;
|
||||||
settings.bgUrl = ev.target.result;
|
settings.bgUrl = ev.target.result;
|
||||||
document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`;
|
document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`;
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
|
|||||||
@@ -33,15 +33,6 @@ function escHtml(str) {
|
|||||||
.replace(/"/g, '"');
|
.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() {
|
function getDefaultBoards() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
+12
-21
@@ -720,32 +720,23 @@ const Timer = {
|
|||||||
await this.open();
|
await this.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close-Event abfangen
|
// Widget-Lifecycle-Events
|
||||||
const origClose = WidgetManager.close.bind(WidgetManager);
|
|
||||||
const self = this;
|
const self = this;
|
||||||
const prevClose = WidgetManager.close;
|
WidgetManager.on('widget:close', (e) => {
|
||||||
WidgetManager.close = function(id) {
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
prevClose.call(WidgetManager, id);
|
|
||||||
if (id === self.WIDGET_ID) {
|
|
||||||
self.onClose();
|
self.onClose();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
// Minimize-Event abfangen
|
WidgetManager.on('widget:minimize', (e) => {
|
||||||
const prevMinimize = WidgetManager.minimize;
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
WidgetManager.minimize = async function(id) {
|
|
||||||
await prevMinimize.call(WidgetManager, id);
|
|
||||||
if (id === self.WIDGET_ID) {
|
|
||||||
self._isOpen = false;
|
self._isOpen = false;
|
||||||
await self.save();
|
self.save();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
// Open-Event abfangen
|
WidgetManager.on('widget:open', (e) => {
|
||||||
const prevOpen = WidgetManager.openWidget;
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
WidgetManager.openWidget = async function(id) {
|
|
||||||
await prevOpen.call(WidgetManager, id);
|
|
||||||
if (id === self.WIDGET_ID) {
|
|
||||||
self._isOpen = true;
|
self._isOpen = true;
|
||||||
const body = WidgetManager.getBody(self.WIDGET_ID);
|
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||||
if (body && body.children.length === 0) {
|
if (body && body.children.length === 0) {
|
||||||
@@ -753,8 +744,8 @@ const Timer = {
|
|||||||
}
|
}
|
||||||
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||||
if (entry) self._bindKeyboard(entry.el);
|
if (entry) self._bindKeyboard(entry.el);
|
||||||
await self.save();
|
self.save();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+53
-6
@@ -9,6 +9,27 @@ const WidgetManager = {
|
|||||||
_topZ: 100,
|
_topZ: 100,
|
||||||
STORAGE_KEY: 'widgetStates',
|
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
|
* Widget erstellen und in DOM einfuegen
|
||||||
* @param {string} type - 'note'
|
* @param {string} type - 'note'
|
||||||
@@ -31,7 +52,7 @@ const WidgetManager = {
|
|||||||
const el = this._buildDOM(state);
|
const el = this._buildDOM(state);
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
this._widgets.set(id, { el, type, state });
|
this._widgets.set(id, { el, type, state, _minimizing: false });
|
||||||
this._initDrag(el);
|
this._initDrag(el);
|
||||||
this._initResize(el);
|
this._initResize(el);
|
||||||
this.bringToFront(id);
|
this.bringToFront(id);
|
||||||
@@ -144,22 +165,47 @@ const WidgetManager = {
|
|||||||
const entry = this._widgets.get(id);
|
const entry = this._widgets.get(id);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
entry.el.remove();
|
entry.el.remove();
|
||||||
|
this._emitter.dispatchEvent(new CustomEvent('widget:close', { detail: { id } }));
|
||||||
this._widgets.delete(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
|
* @param {string} id
|
||||||
*/
|
*/
|
||||||
async minimize(id) {
|
async minimize(id) {
|
||||||
const entry = this._widgets.get(id);
|
const entry = this._widgets.get(id);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
entry.state.open = false;
|
entry.state.open = false;
|
||||||
|
entry._minimizing = true;
|
||||||
entry.el.classList.add('widget-minimized');
|
entry.el.classList.add('widget-minimized');
|
||||||
setTimeout(() => {
|
|
||||||
entry.el.style.display = 'none';
|
const MINIMIZE_FALLBACK_MS = 350;
|
||||||
}, 250);
|
|
||||||
|
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();
|
await this.save();
|
||||||
|
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,14 +215,15 @@ const WidgetManager = {
|
|||||||
async openWidget(id) {
|
async openWidget(id) {
|
||||||
const entry = this._widgets.get(id);
|
const entry = this._widgets.get(id);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
entry._minimizing = false;
|
||||||
entry.state.open = true;
|
entry.state.open = true;
|
||||||
entry.el.style.display = 'flex';
|
entry.el.style.display = 'flex';
|
||||||
// Naechster Frame fuer Animation
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
entry.el.classList.remove('widget-minimized');
|
entry.el.classList.remove('widget-minimized');
|
||||||
});
|
});
|
||||||
this.bringToFront(id);
|
this.bringToFront(id);
|
||||||
await this.save();
|
await this.save();
|
||||||
|
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user