Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42e3cf0dec | |||
| 2877edee69 | |||
| 8c509647da | |||
| d041c66dfb | |||
| 520a062049 | |||
| 327bcd3385 | |||
| 530196ddf7 | |||
| 17eac64683 | |||
| 1d17f4d11f | |||
| b3288b47eb | |||
| 84976f5a10 | |||
| 5b18bed9b5 | |||
| 70f3f705b4 | |||
| 1d9e9dab81 | |||
| 8401535900 | |||
| 390a9b2f94 | |||
| dcc015abd2 | |||
| 456be8ba26 | |||
| 767c7c80aa | |||
| 43403bc755 | |||
| 4897781848 | |||
| 5feadcc90c | |||
| a37f34eeac | |||
| f473697fb2 | |||
| 9383726198 | |||
| 7d390792ea | |||
| 17506011c1 | |||
| c8ff4dd9d2 | |||
| 79459beb98 | |||
| 9a682d49a9 | |||
| a9928706ad | |||
| 83df926979 | |||
| 9800e6c949 | |||
| ba5f5c4978 | |||
| 22203d25a7 | |||
| da5d8faafa | |||
| 127aba12eb | |||
| 4031b429ad | |||
| 62c1ecab8d | |||
| 061c3708bc | |||
| 9abfefc0e0 | |||
| 36d917b420 | |||
| fcaea64604 | |||
| 6eaa3457d0 | |||
| 091195cdef | |||
| b5b0ac3471 | |||
| 7b16db96b9 | |||
| 3872f4cf12 | |||
| e7a064783f | |||
| 42860bb95d | |||
| 6a27d9b307 | |||
| c96922d1bb | |||
| 2daccf4ecc | |||
| ecb44facb5 | |||
| e1fb580525 | |||
| a946e66c6c | |||
| 601350c5c6 | |||
| 47393012f2 | |||
| cbd8b5e6fb | |||
| 2b16b19246 | |||
| d9d40c350d | |||
| 55e371f506 | |||
| eda5fba8f3 | |||
| f2e078b593 | |||
| 80af8df8b0 | |||
| 4e527b19d5 | |||
| 3e93efb785 | |||
| 02c36dba09 | |||
| 085cca2812 | |||
| 0a93340792 | |||
| 87cd070beb | |||
| 6004203339 | |||
| 278eda7d69 | |||
| abddc59f49 | |||
| 24e9aa408b | |||
| 2bdee5f215 | |||
| a6d14f9267 | |||
| bb0c490cc7 | |||
| 2ab3965640 | |||
| 061669a7cc | |||
| 5a7d7feace | |||
| 47eb475887 | |||
| c6d2792332 | |||
| ab07c94141 | |||
| df8a187af2 | |||
| ccbd27916c | |||
| 2b6b2c06c2 | |||
| 0ed3a8fe64 | |||
| 0683686fcb | |||
| 486438772d | |||
| 27e4c8243c | |||
| 829914a271 | |||
| af2a5c4912 | |||
| 6a64df96d0 | |||
| e5f0bf3fed | |||
| 25e916c3be | |||
| 1d7680330d | |||
| 9323fb69e8 | |||
| 07cf13efcd | |||
| 4c3eec7631 | |||
| 71225308d3 | |||
| a5958d47a4 | |||
| 99c61cf7e3 | |||
| 32f4c92f1b | |||
| 4c7a33a6fa | |||
| 9eb0bc1c3e | |||
| 0fb0eec7df | |||
| 0d4708bf11 | |||
| f2b070e201 | |||
| 8176f91d4c | |||
| d68bb35e7a | |||
| 10c70f8bf9 | |||
| 28b9061756 | |||
| 60a1bec00d | |||
| 153db9c24d | |||
| 2e691b8b51 | |||
| 11419bd589 | |||
| 27fa4f53af | |||
| 8fdd46beec | |||
| 3dd9723271 | |||
| 2f23c13de1 | |||
| f5cebd8d34 | |||
| 10318008e6 | |||
| 50319f8ba9 | |||
| b71e8cde1b | |||
| 2487ac772f | |||
| 7be391de99 | |||
| cebf277a5d | |||
| 92c5b23b44 | |||
| 7f22627272 | |||
| 9b6515aab3 | |||
| 675e21d886 | |||
| 536e0771a4 | |||
| 02cdee76a8 | |||
| b6d347cd15 | |||
| 6704f4c955 | |||
| a3e21a760f | |||
| 82dd6e026a | |||
| 2430d65e3a | |||
| 30df93a4cc | |||
| b92ea5a1a4 | |||
| fde1fdd002 | |||
| 7cda3019c8 | |||
| 3de1dd3b8b | |||
| 63825cd393 | |||
| c6c0d5c468 | |||
| dbd209bc2b | |||
| 7900962c5a | |||
| 1bbdbdef1c | |||
| f07200cd8e | |||
| ab165d4f75 | |||
| 4a66015258 | |||
| d0f870ace1 | |||
| daea57a9df | |||
| f937f7c39c | |||
| 3ab8847f31 | |||
| 36335d3cc4 | |||
| 1b39ac863b | |||
| 522b177470 | |||
| f2d4e22b86 |
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
|
||||||
- name: Projektstruktur prüfen
|
- name: Projektstruktur prüfen
|
||||||
run: |
|
run: |
|
||||||
@@ -45,6 +45,11 @@ jobs:
|
|||||||
assert m.get('name'), 'Chrome: Name fehlt'
|
assert m.get('name'), 'Chrome: Name fehlt'
|
||||||
assert m.get('version'), 'Chrome: Version fehlt'
|
assert m.get('version'), 'Chrome: Version fehlt'
|
||||||
assert 'storage' in m.get('permissions', []), 'Chrome: Storage Permission fehlt'
|
assert 'storage' in m.get('permissions', []), 'Chrome: Storage Permission fehlt'
|
||||||
|
assert 'activeTab' in m.get('permissions', []), 'Chrome: activeTab Permission fehlt (Quick Save v2.3)'
|
||||||
|
assert 'background' in m, 'Chrome: background-Key fehlt (Service Worker v2.3)'
|
||||||
|
assert 'service_worker' in m.get('background', {}), 'Chrome: background.service_worker fehlt'
|
||||||
|
assert isinstance(m.get('commands'), dict) and 'quick-save' in m['commands'], 'Chrome: commands.quick-save fehlt (Quick Save v2.3)'
|
||||||
|
assert 'action' in m, 'Chrome: action-Key fehlt (Badge-Bestätigung v2.3)'
|
||||||
print('manifest.json (V3) OK — Version:', m['version'])
|
print('manifest.json (V3) OK — Version:', m['version'])
|
||||||
|
|
||||||
with open('manifest.firefox.json') as f:
|
with open('manifest.firefox.json') as f:
|
||||||
@@ -52,6 +57,11 @@ jobs:
|
|||||||
assert mf.get('manifest_version') == 3, 'Firefox: Manifest V3 erwartet'
|
assert mf.get('manifest_version') == 3, 'Firefox: Manifest V3 erwartet'
|
||||||
assert mf['version'] == m['version'], 'Firefox: Version stimmt nicht mit Chrome überein!'
|
assert mf['version'] == m['version'], 'Firefox: Version stimmt nicht mit Chrome überein!'
|
||||||
assert 'browser_specific_settings' in mf, 'Firefox: browser_specific_settings fehlt'
|
assert 'browser_specific_settings' in mf, 'Firefox: browser_specific_settings fehlt'
|
||||||
|
assert 'activeTab' in mf.get('permissions', []), 'Firefox: activeTab Permission fehlt (Quick Save v2.3)'
|
||||||
|
assert 'background' in mf, 'Firefox: background-Key fehlt (Event-Page v2.3)'
|
||||||
|
assert 'scripts' in mf.get('background', {}), 'Firefox: background.scripts fehlt (Event-Page, kein service_worker)'
|
||||||
|
assert isinstance(mf.get('commands'), dict) and 'quick-save' in mf['commands'], 'Firefox: commands.quick-save fehlt (Quick Save v2.3)'
|
||||||
|
assert 'action' in mf, 'Firefox: action-Key fehlt (Badge-Bestätigung v2.3)'
|
||||||
print('manifest.firefox.json (V3) OK — Version:', mf['version'])
|
print('manifest.firefox.json (V3) OK — Version:', mf['version'])
|
||||||
|
|
||||||
with open('manifest.opera.json') as f:
|
with open('manifest.opera.json') as f:
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Wird bei einem vX.Y.Z-Tag-Push ausgeloest. Baut die drei Web-Extension-ZIPs
|
||||||
|
# (Chrome/Firefox/Opera) und haengt sie ans passende Gitea-Release.
|
||||||
|
#
|
||||||
|
# Portiert von GitHub Actions auf Gitea Actions (2026-06): der fruehere
|
||||||
|
# softprops/action-gh-release-Step ist GitHub-spezifisch und laeuft auf Gitea
|
||||||
|
# nicht. Ersetzt durch die Gitea-native release-action (volle gitea.com-URL,
|
||||||
|
# da DEFAULT_ACTIONS_URL=github nackte Namen sonst von github.com zieht).
|
||||||
|
# Muster uebernommen aus HellionChat/.gitea/workflows/release.yml.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
# Manueller Recovery-Trigger: in Gitea "Run workflow" und den Tag (z.B. v2.2.0)
|
||||||
|
# im Ref-Dropdown waehlen, NICHT master. Der Validate-Step unten failt hart
|
||||||
|
# bei einem Nicht-Tag-Ref, weil die release-action GITHUB_REF direkt liest.
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-release:
|
||||||
|
name: Build & Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# release-action liest GITHUB_REF direkt (kein tag_name-Input). Vorab
|
||||||
|
# validieren, damit manuelle Dispatches von einem Branch-Ref hier laut
|
||||||
|
# scheitern statt nach einem vollen Build.
|
||||||
|
- name: Validate tag ref
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then
|
||||||
|
echo "::error::Release-Workflow muss auf einem v*-Tag laufen, got ${GITHUB_REF}"
|
||||||
|
echo "::error::Tag pushen, oder im workflow_dispatch-Ref-Dropdown den Tag (nicht master) waehlen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create Chrome/Edge ZIP (Manifest V3)
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \
|
||||||
|
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
|
||||||
|
-x "*.git*" "dist/*" ".github/*" ".gitea/*" "src/js/opera/*"
|
||||||
|
|
||||||
|
- name: Create Firefox ZIP (Manifest V3)
|
||||||
|
run: |
|
||||||
|
cp manifest.json manifest.chrome-backup.json
|
||||||
|
cp manifest.firefox.json manifest.json
|
||||||
|
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \
|
||||||
|
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
|
||||||
|
-x "*.git*" "dist/*" ".github/*" ".gitea/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*"
|
||||||
|
mv manifest.chrome-backup.json manifest.json
|
||||||
|
|
||||||
|
- name: Create Opera/Opera GX ZIP (Manifest V3 + workaround)
|
||||||
|
run: |
|
||||||
|
cp manifest.json manifest.chrome-backup.json
|
||||||
|
cp manifest.opera.json manifest.json
|
||||||
|
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \
|
||||||
|
manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ _locales/ \
|
||||||
|
-x "*.git*" "dist/*" ".github/*" ".gitea/*" "manifest.chrome-backup.json" "manifest.opera.json"
|
||||||
|
mv manifest.chrome-backup.json manifest.json
|
||||||
|
|
||||||
|
- name: Generate SHA256 checksums
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
sha256sum *.zip > checksums-sha256.txt
|
||||||
|
cat checksums-sha256.txt
|
||||||
|
|
||||||
|
# Release per Gitea-API (curl), NICHT via gitea.com/actions/release-action: die ist `using: go`
|
||||||
|
# und stirbt auf diesem Runner mit exit 127 ("go not found"), weil act_runner v0.6.1 die go-Action
|
||||||
|
# weder im Job-Image noch im Runner kompiliert bekommt. curl + python3 sind im Job-Image vorhanden
|
||||||
|
# und laufen als normaler Step -> unabhaengig von go-Toolchain, Action-Cache und @main-Drift.
|
||||||
|
# GITHUB_API_URL/GITHUB_REPOSITORY/GITHUB_TOKEN injiziert Gitea Actions automatisch.
|
||||||
|
- name: Create release & upload assets (Gitea API)
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
API="${GITHUB_API_URL:-https://gitea.hellion-forge.cloud/api/v1}"
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
AUTH="Authorization: token ${GITEA_TOKEN}"
|
||||||
|
|
||||||
|
# Release-Request-JSON (Body inkl. Installationshinweise) als python-Einzeiler bauen
|
||||||
|
# (mehrzeilig wuerde den YAML-run-Block brechen: Zeilen auf Spalte 0).
|
||||||
|
REQ=$(python3 -c 'import json,os; t=os.environ["TAG"]; body="## Hellion NewTab "+t+"\n\n### Installation\n- **Chrome / Edge / Brave / Vivaldi:** `hellion-newtab-"+t+"-chrome.zip`\n- **Firefox:** `hellion-newtab-"+t+"-firefox.zip`\n- **Opera / Opera GX:** `hellion-newtab-"+t+"-opera.zip`\n\nVollstaendige Installationsanleitung siehe README.\n\n### Checksums\n`checksums-sha256.txt` zum Verifizieren der Dateiintegritaet."; print(json.dumps({"tag_name": t, "name": "Hellion NewTab "+t, "body": body}))')
|
||||||
|
|
||||||
|
# Idempotent: existierendes Release zum Tag wiederverwenden, sonst anlegen.
|
||||||
|
REL_ID=$(curl -sf -H "$AUTH" "$API/repos/$REPO/releases/tags/$TAG" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true)
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
REL_ID=$(printf '%s' "$REQ" \
|
||||||
|
| curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" -d @- "$API/repos/$REPO/releases" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
|
||||||
|
echo "Release angelegt: $REL_ID"
|
||||||
|
else
|
||||||
|
echo "Release existiert bereits, wiederverwenden: $REL_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Vorhandene gleichnamige Assets entfernen (idempotent bei Re-Runs), dann hochladen.
|
||||||
|
EXIST=$(curl -sf -H "$AUTH" "$API/repos/$REPO/releases/$REL_ID/assets" 2>/dev/null || echo '[]')
|
||||||
|
for f in dist/hellion-newtab-$TAG-chrome.zip dist/hellion-newtab-$TAG-firefox.zip dist/hellion-newtab-$TAG-opera.zip dist/checksums-sha256.txt; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
aid=$(printf '%s' "$EXIST" | NAME="$name" python3 -c 'import sys,json,os;n=os.environ["NAME"];print(next((a["id"] for a in json.load(sys.stdin) if a.get("name")==n), ""))' 2>/dev/null || true)
|
||||||
|
if [ -n "$aid" ]; then
|
||||||
|
echo "ersetze vorhandenes Asset $name (id $aid)"
|
||||||
|
curl -sf -X DELETE -H "$AUTH" "$API/repos/$REPO/releases/$REL_ID/assets/$aid" >/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "Upload $name ..."
|
||||||
|
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" "$API/repos/$REPO/releases/$REL_ID/assets?name=$name" >/dev/null
|
||||||
|
done
|
||||||
|
echo "Release $TAG fertig: alle Assets hochgeladen."
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name: Security
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
uses: JonKazama-Hellion/security-workflows/.gitea/workflows/security-scan.yml@main
|
||||||
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Release — creates ZIP packages for Chrome, Firefox and Opera on new tag
|
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-release:
|
|
||||||
name: Build & Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Extract version from tag
|
|
||||||
id: version
|
|
||||||
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Create Chrome/Edge ZIP (Manifest V3)
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \
|
|
||||||
manifest.json newtab.html src/js/*.js src/css/ assets/ \
|
|
||||||
-x "*.git*" "dist/*" ".github/*" "src/js/opera/*"
|
|
||||||
|
|
||||||
- name: Create Firefox ZIP (Manifest V3)
|
|
||||||
run: |
|
|
||||||
cp manifest.json manifest.chrome-backup.json
|
|
||||||
cp manifest.firefox.json manifest.json
|
|
||||||
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \
|
|
||||||
manifest.json newtab.html src/js/*.js src/css/ assets/ \
|
|
||||||
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*"
|
|
||||||
mv manifest.chrome-backup.json manifest.json
|
|
||||||
|
|
||||||
- name: Create Opera/Opera GX ZIP (Manifest V3 + workaround)
|
|
||||||
run: |
|
|
||||||
cp manifest.json manifest.chrome-backup.json
|
|
||||||
cp manifest.opera.json manifest.json
|
|
||||||
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \
|
|
||||||
manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ \
|
|
||||||
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.opera.json"
|
|
||||||
mv manifest.chrome-backup.json manifest.json
|
|
||||||
|
|
||||||
- name: Generate SHA256 checksums
|
|
||||||
run: |
|
|
||||||
cd dist
|
|
||||||
sha256sum *.zip > checksums-sha256.txt
|
|
||||||
cat checksums-sha256.txt
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
name: "Hellion NewTab ${{ steps.version.outputs.tag }}"
|
|
||||||
body: |
|
|
||||||
## Hellion NewTab ${{ steps.version.outputs.tag }}
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
- **Chrome / Edge / Brave / Vivaldi:** `hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip`
|
|
||||||
- **Firefox:** `hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip`
|
|
||||||
- **Opera / Opera GX:** `hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip`
|
|
||||||
|
|
||||||
See [README](README.md) for the full installation instructions.
|
|
||||||
|
|
||||||
### Checksums
|
|
||||||
See `checksums-sha256.txt` to verify file integrity.
|
|
||||||
files: |
|
|
||||||
dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip
|
|
||||||
dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip
|
|
||||||
dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip
|
|
||||||
dist/checksums-sha256.txt
|
|
||||||
generate_release_notes: true
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Sicherheitsprüfung — läuft bei Push und PR auf main/master
|
|
||||||
name: Security Scan
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, master]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, master]
|
|
||||||
schedule:
|
|
||||||
# Wöchentlich Montag 06:00 UTC
|
|
||||||
- cron: '0 6 * * 1'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
codeql:
|
|
||||||
name: CodeQL Analysis
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@v3
|
|
||||||
with:
|
|
||||||
languages: javascript
|
|
||||||
|
|
||||||
- name: Run CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@v3
|
|
||||||
|
|
||||||
dependency-review:
|
|
||||||
name: Dependency Review
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Dependency Review
|
|
||||||
uses: actions/dependency-review-action@v4
|
|
||||||
@@ -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,98 @@ All notable changes per version. Format based on [Keep a Changelog](https://keep
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.3.0] — 2026-06-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Command Palette (Ctrl+K)** — Overlay that live-filters bookmarks (title and URL) and board names from the keyboard. Arrow-key navigation, Enter opens the match, Escape closes. Read-only navigation, separate from the web search bar. Combobox/listbox ARIA pattern with focus trap and focus return. New DE/EN i18n strings.
|
||||||
|
- **Trash** — Deleted bookmarks and boards move to a 30-day trash instead of vanishing. Restore or permanently remove them from a new Settings section; entries older than 30 days are cleaned up automatically. Stored under its own storage key with a hard size cap so it cannot exhaust the storage quota.
|
||||||
|
- **Quick Save** — A global keyboard shortcut (default Alt+Shift+S, configurable in the browser shortcut settings) saves the current tab into a fixed Inbox board from any page. Backed by a background worker (service worker on Chrome/Opera, event page on Firefox) that appends to a dedicated pending queue, which the dashboard drains into the Inbox — separate write domains, so a save can never clobber the boards. A badge confirms the save, and open dashboard tabs sync the new bookmark live via a storage-change listener.
|
||||||
|
- **Free layout (bonus)** — Boards can be dragged to free positions via a drag handle, persisted per board. Positions are clamped back into view when the window shrinks, and the layout falls back to a stacked column on small screens. Each board can be pinned with a lock button: a locked board cannot be moved (its drag handle is hidden), preventing accidental repositioning. A drag only counts past a small movement threshold, so a mere click on the handle never shifts a board.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The bookmark- and board-delete paths no longer remove entries immediately; deletions now route through the trash.
|
||||||
|
- Chrome and Firefox manifests gain a background worker, an `action` entry and the `activeTab` / `commands` permissions to support Quick Save. Opera keeps its existing `tabs` permission and redirect worker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.0] — 2026-06-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **View Transitions** — Native cross-fade on theme switch and central modals (Settings, Theme-Picker, custom dialogs, bookmark import, add-board, add-bookmark, rename). Feature-detected via `document.startViewTransition`, instant swap on older browsers. Widgets, notebook sidebar and onboarding deliberately excluded.
|
||||||
|
- **`color-scheme: dark`** — Declares the dark UA scheme so native scrollbars and form controls match the dark themes.
|
||||||
|
- **Accessibility pass** — `role="dialog"` / `aria-modal` / `aria-labelledby` on Settings and Theme-Picker with new focus trap, Escape handling and focus return; `role="toolbar"` + per-button `aria-label` on the widget toolbar; keyboard-operable theme cards (`role="button"`, `tabindex`, Enter/Space); `role="switch"` + `aria-checked` on settings toggles; focusable boards and bookmarks; visible `:focus-visible` ring tinted in the theme accent. New ARIA strings run through the i18n pipeline. Verified with Lighthouse and the axe DevTools extension, not a formal WCAG 2.2 AA audit.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`color-mix()` token refactor** — Accent-derived color tokens now computed via `color-mix()` from `var(--accent)`, classified per theme (formula vs. override). Theme-specific alpha values and real special colors stay overrides; no visible theme change. `--border-accent` `:root` drift (179,92,255 → 179,89,255) fixed at both the Nebula block and the `:root` default.
|
||||||
|
- **`@layer` cascade ordering** — CSS reorganized into six layers (base / theme / layout / components / theme-overrides / utilities) so theme component overrides win deterministically instead of relying on selector specificity.
|
||||||
|
- **`clamp()` fluid typography** — Clock, logo, board titles and main spacing scale fluidly via `clamp()`. Existing 768px / 480px breakpoints kept as a safety net.
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
- **`prefers-reduced-motion`** — Unlayered `@media` block disables transitions and animations, including the `::view-transition-*` pseudo-elements. The 350ms widget teardown fallback timer is retained so widgets still close when `transitionend` no longer fires under reduced motion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.0] — 2026-04-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Calculator Tab-System:** 6 Modi über Tab-Leiste erreichbar (Standard, Scientific, Unit, SAT, FAC, STA)
|
||||||
|
- **Scientific-Modus:** Wurzel, Potenz, Pi, Euler, Vorzeichen-Wechsel + Formel-Helfer (Kreis, Pythagoras, Prozent, Temperatur)
|
||||||
|
- **Unit-Converter:** 6 Kategorien (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche) mit Live-Konvertierung und Swap
|
||||||
|
- **Satisfactory Calculator:** Items/Min, Overclock-Power (Exponent 1.321928), Maschinen-Rechner
|
||||||
|
- **Factorio Calculator:** Assembler-Ratios, Belt-Throughput, Maschinen-Rechner mit Belt-Empfehlung
|
||||||
|
- **Stationeers Calculator:** Idealgas (PV=nRT), Furnace/Verbrennung, Solar/Batterie-Dimensionierung, Atmosphären-Mixer
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Parser um `^` (Potenz, rechts-assoziativ) und `sqrt()` erweitert
|
||||||
|
- Calculator-Widget Auto-Resize auf 320×480 für komplexe Modi
|
||||||
|
- ~110 neue i18n-Keys (DE + EN)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v2.0.1 — 16.04.2026
|
||||||
|
|
||||||
|
#### Security
|
||||||
|
|
||||||
|
- **Background URL validation** — Only `blob:` and `data:image/` protocols allowed in CSS `backgroundImage` (prevents CSS injection via manipulated storage)
|
||||||
|
- **Import URL validation** — `javascript:`, `data:`, and other unsafe protocols are blocked during JSON import
|
||||||
|
- **Immutable import mapping** — Imported boards, bookmarks, and notes are sanitized with explicit field selection and string length limits
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- **Widget minimize race condition** — Replaced `setTimeout` with `transitionend` event; `openWidget()` during animation no longer causes display glitch
|
||||||
|
- **Notes import mutation** — Import now uses `Notes.init()` instead of directly setting `Notes._notes`
|
||||||
|
- **Complete i18n coverage** — 5 header button tooltips and 3 settings button texts now have `data-i18n` attributes (10 new translation keys)
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- **Widget event system** — `WidgetManager` now dispatches `widget:close`, `widget:minimize`, `widget:open` CustomEvents via `EventTarget`. Calculator, Timer, and ImageRef use `WidgetManager.on()` instead of monkey-patching
|
||||||
|
- **Local favicon icons** — Replaced Google Favicons API with local colored letter icons (deterministic hue per title). Zero external network requests, Brave Shields compatible
|
||||||
|
- **backdrop-filter fallback** — `@supports not (backdrop-filter)` block with `--bg-solid-fallback` per theme for Brave Shields compatibility
|
||||||
|
- **Clock interval cleanup** — `setInterval` ID stored in variable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v2.0.0 — 22.03.2026
|
||||||
|
|
||||||
|
#### New Features
|
||||||
|
|
||||||
|
- **Internationalization (i18n)** — Full DE/EN language support with runtime switching
|
||||||
|
- Language setting in Settings panel: German, English or Auto-detect (browser language)
|
||||||
|
- `i18n.js` module with ~220+ string keys, `t(key, vars?)` helper and `data-i18n` HTML attributes
|
||||||
|
- `_locales/de/` and `_locales/en/` for manifest-level i18n (`__MSG_extName__`, `__MSG_extDesc__`)
|
||||||
|
- `<html lang>` attribute updates dynamically when language changes
|
||||||
|
- All modules migrated: dialog, boards, onboarding, notes, calculator, timer, image-ref, data, bookmark-import, storage, settings, widgets, app
|
||||||
|
|
||||||
|
#### Technical
|
||||||
|
|
||||||
|
- New script load order: `storage → state → i18n → dialog → ...`
|
||||||
|
- `applyLanguage()` scans DOM for `data-i18n`, `data-i18n-placeholder`, `data-i18n-title`
|
||||||
|
- Onboarding slides use i18n keys instead of hardcoded text (rendered at display time)
|
||||||
|
- Clock day/month names via i18n keys instead of hardcoded arrays
|
||||||
|
- `resolveLang()` helper for DRY language resolution (auto → browser detect)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### v1.10.0 — 22.03.2026
|
### v1.10.0 — 22.03.2026
|
||||||
|
|
||||||
#### Themes
|
#### Themes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ⬡ Hellion Dashboard v1.9.0
|
# ⬡ Hellion Dashboard v2.0.0
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -10,7 +10,8 @@
|
|||||||
**No account. No subscription. No cloud. All data stays 100% local.**
|
**No account. No subscription. No cloud. All data stays 100% local.**
|
||||||
|
|
||||||
A personal bookmark dashboard as a browser extension.
|
A personal bookmark dashboard as a browser extension.
|
||||||
Boards, drag & drop, 11 themes, search bar, widget system with notes, calculator, timer and more. All in the browser, all offline.
|
Boards, drag & drop, 11 themes, search bar, widget system with notes, calculator, timer and more.
|
||||||
|
Full DE/EN language support with runtime switching. All in the browser, all offline.
|
||||||
No external data transmission, no trackers, no analytics, no ads.
|
No external data transmission, no trackers, no analytics, no ads.
|
||||||
|
|
||||||
Developed by **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion.
|
Developed by **[Hellion Online Media — Florian Wathling](https://hellion-media.de)** — JonKazama-Hellion.
|
||||||
@@ -89,6 +90,12 @@ What you see is what's saved. No magic.
|
|||||||
| Avorion | Own work, screenshot from Avorion, Hellion Initiative ship | Hellion Online Media |
|
| Avorion | Own work, screenshot from Avorion, Hellion Initiative ship | Hellion Online Media |
|
||||||
| Hellion Stealth | Screenshot from Star Citizen by Cloud Imperium Games | Fan Content |
|
| Hellion Stealth | Screenshot from Star Citizen by Cloud Imperium Games | Fan Content |
|
||||||
|
|
||||||
|
### Language Support (i18n)
|
||||||
|
|
||||||
|
- German and English with runtime switching via Settings
|
||||||
|
- Auto-detect from browser language, manual override available
|
||||||
|
- All UI elements, dialogs, onboarding and widget labels fully translated
|
||||||
|
|
||||||
### Onboarding & Dialogs
|
### Onboarding & Dialogs
|
||||||
|
|
||||||
- 7-step welcome flow on first launch with widget explanation and optional gaming starter board
|
- 7-step welcome flow on first launch with widget explanation and optional gaming starter board
|
||||||
@@ -103,7 +110,7 @@ What you see is what's saved. No magic.
|
|||||||
- Compact mode, shorten titles, search bar toggle, open links in new tab, descriptions, hide extra bookmarks
|
- Compact mode, shorten titles, search bar toggle, open links in new tab, descriptions, hide extra bookmarks
|
||||||
- JSON export & import (backup & restore)
|
- JSON export & import (backup & restore)
|
||||||
- Onboarding repeatable
|
- Onboarding repeatable
|
||||||
- All UI labels in German (English coming in v2.1)
|
- Language setting: German, English or auto-detect
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -223,10 +230,15 @@ hellion-newtab/
|
|||||||
├── SECURITY.md # Security policy and reporting
|
├── SECURITY.md # Security policy and reporting
|
||||||
├── DISCLAIMER.md # Disclaimer and legal
|
├── DISCLAIMER.md # Disclaimer and legal
|
||||||
│
|
│
|
||||||
|
├── _locales/
|
||||||
|
│ ├── de/messages.json # Manifest-level i18n (German)
|
||||||
|
│ └── en/messages.json # Manifest-level i18n (English)
|
||||||
|
│
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ ├── storage.js # Storage abstraction + quota check
|
│ │ ├── storage.js # Storage abstraction + quota check
|
||||||
│ │ ├── state.js # Global state, defaults, helpers
|
│ │ ├── state.js # Global state, defaults, helpers
|
||||||
|
│ │ ├── i18n.js # Internationalization (DE/EN, ~220+ keys, t() helper)
|
||||||
│ │ ├── dialog.js # Custom dialog system (HellionDialog.alert/confirm)
|
│ │ ├── dialog.js # Custom dialog system (HellionDialog.alert/confirm)
|
||||||
│ │ ├── themes.js # Theme definitions & application (11 themes)
|
│ │ ├── themes.js # Theme definitions & application (11 themes)
|
||||||
│ │ ├── boards.js # Board/bookmark rendering, event delegation, modals
|
│ │ ├── boards.js # Board/bookmark rendering, event delegation, modals
|
||||||
@@ -272,7 +284,7 @@ hellion-newtab/
|
|||||||
|
|
||||||
- **Zero Dependencies** — No npm, no build, no framework. Runs directly
|
- **Zero Dependencies** — No npm, no build, no framework. Runs directly
|
||||||
- **Privacy First** — All data local, no server contact
|
- **Privacy First** — All data local, no server contact
|
||||||
- **Modular** — 15 JS files with clear responsibilities
|
- **Modular** — 16 JS files with clear responsibilities
|
||||||
- **Responsive** — Tablet (768px) and smartphone (480px) breakpoints
|
- **Responsive** — Tablet (768px) and smartphone (480px) breakpoints
|
||||||
- **Secure** — `createElement` instead of `innerHTML`, URL validation, storage error handling
|
- **Secure** — `createElement` instead of `innerHTML`, URL validation, storage error handling
|
||||||
- **Event Delegation** — One listener per board list instead of per bookmark (performance)
|
- **Event Delegation** — One listener per board list instead of per bookmark (performance)
|
||||||
@@ -305,8 +317,8 @@ hellion-newtab/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a release:
|
# Create a release:
|
||||||
git tag v1.10.0
|
git tag v2.0.0
|
||||||
git push origin v1.10.0
|
git push origin v2.0.0
|
||||||
# → GitHub Action automatically creates release with ZIP files
|
# → GitHub Action automatically creates release with ZIP files
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extName": { "message": "Hellion NewTab" },
|
||||||
|
"extDesc": { "message": "Persönliches Bookmark-Dashboard mit Boards, Widgets und 11 Themes. Komplett lokal, keine Cloud, kein Tracking." },
|
||||||
|
"cmdQuickSave": { "message": "Aktuellen Tab in die Inbox speichern" },
|
||||||
|
"quickSaveBadge": { "message": "OK" },
|
||||||
|
"quickSaveSaved": { "message": "Gespeichert" },
|
||||||
|
"quickSaveNoTab": { "message": "Kein Tab" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extName": { "message": "Hellion NewTab" },
|
||||||
|
"extDesc": { "message": "Personal bookmark dashboard with boards, widgets, and 11 themes. Local-only, no cloud, no tracking." },
|
||||||
|
"cmdQuickSave": { "message": "Save current tab to Inbox" },
|
||||||
|
"quickSaveBadge": { "message": "OK" },
|
||||||
|
"quickSaveSaved": { "message": "Saved" },
|
||||||
|
"quickSaveNoTab": { "message": "No tab" }
|
||||||
|
}
|
||||||
+14
-8
@@ -27,21 +27,23 @@ HOM_NewTab_Project/
|
|||||||
│ ├── css/
|
│ ├── css/
|
||||||
│ │ └── main.css # All styles, 11 themes, responsive breakpoints
|
│ │ └── main.css # All styles, 11 themes, responsive breakpoints
|
||||||
│ └── js/
|
│ └── js/
|
||||||
│ ├── dialog.js # Custom dialog system (alert, confirm)
|
|
||||||
│ ├── storage.js # Storage abstraction layer
|
│ ├── storage.js # Storage abstraction layer
|
||||||
│ ├── state.js # Global state, defaults, helpers
|
│ ├── state.js # Global state, defaults, helpers
|
||||||
|
│ ├── i18n.js # Internationalization (DE/EN, t() helper)
|
||||||
|
│ ├── dialog.js # Custom dialog system (alert, confirm)
|
||||||
│ ├── themes.js # Theme definitions & application (11 themes)
|
│ ├── themes.js # Theme definitions & application (11 themes)
|
||||||
│ ├── boards.js # Board/bookmark rendering & events
|
|
||||||
│ ├── drag.js # Drag & drop (Pointer Events API)
|
│ ├── drag.js # Drag & drop (Pointer Events API)
|
||||||
|
│ ├── boards.js # Board/bookmark rendering & events
|
||||||
│ ├── settings.js # Settings panel, toggles, theme picker
|
│ ├── settings.js # Settings panel, toggles, theme picker
|
||||||
│ ├── search.js # Search bar (Google, DuckDuckGo, Bing)
|
│ ├── search.js # Search bar (Google, DuckDuckGo, Bing)
|
||||||
│ ├── onboarding.js # First-run onboarding flow
|
|
||||||
│ ├── widgets.js # Widget manager (registry, drag, resize)
|
│ ├── widgets.js # Widget manager (registry, drag, resize)
|
||||||
│ ├── notes.js # Notes/checklists (multi-instance widgets)
|
│ ├── notes.js # Notes/checklists (multi-instance widgets)
|
||||||
│ ├── calculator.js # Calculator widget (single-instance)
|
│ ├── calculator.js # Calculator widget (single-instance)
|
||||||
│ ├── timer.js # Timer/countdown widget (single-instance)
|
│ ├── timer.js # Timer/countdown widget (single-instance)
|
||||||
│ ├── image-ref.js # Image reference widget (multi-instance)
|
│ ├── image-ref.js # Image reference widget (multi-instance)
|
||||||
|
│ ├── bookmark-import.js # Browser bookmark import (chrome.bookmarks API)
|
||||||
│ ├── data.js # JSON export/import (backup & restore)
|
│ ├── data.js # JSON export/import (backup & restore)
|
||||||
|
│ ├── onboarding.js # First-run onboarding flow
|
||||||
│ └── app.js # Init, clock, global events (entry point)
|
│ └── app.js # Init, clock, global events (entry point)
|
||||||
├── assets/
|
├── assets/
|
||||||
│ ├── fonts/ # Local fonts (Rajdhani, Inter, Cinzel)
|
│ ├── fonts/ # Local fonts (Rajdhani, Inter, Cinzel)
|
||||||
@@ -58,21 +60,23 @@ Each module has exactly one responsibility. Communication happens through global
|
|||||||
|
|
||||||
| Module | Responsibility |
|
| Module | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `dialog.js` | `HellionDialog.alert()` and `HellionDialog.confirm()` — custom styled dialogs that replace native browser popups. Loaded first so every other module can use it. |
|
|
||||||
| `storage.js` | The **only** place that touches `chrome.storage` / `localStorage`. Everything else goes through `Store.get()` / `Store.set()`. |
|
| `storage.js` | The **only** place that touches `chrome.storage` / `localStorage`. Everything else goes through `Store.get()` / `Store.set()`. |
|
||||||
| `state.js` | Global `boards` and `settings` arrays, default values, `uid()`, `escHtml()`, `getFaviconUrl()`. |
|
| `state.js` | Global `boards` and `settings` arrays, default values, `uid()`, `escHtml()`, `getFaviconUrl()`. |
|
||||||
|
| `i18n.js` | Internationalization module. `STRINGS` object with ~220+ keys (DE/EN), `t(key, vars?)` helper, `applyLanguage()` DOM scanner, `setLanguage()`, `I18n.init()`. |
|
||||||
|
| `dialog.js` | `HellionDialog.alert()` and `HellionDialog.confirm()` — custom styled dialogs that replace native browser popups. |
|
||||||
| `themes.js` | Applies theme CSS variables. 11 themes, each with its own `[data-theme]` block in `main.css`. |
|
| `themes.js` | Applies theme CSS variables. 11 themes, each with its own `[data-theme]` block in `main.css`. |
|
||||||
| `boards.js` | Renders boards and bookmarks. Event delegation on board containers. |
|
| `boards.js` | Renders boards and bookmarks. Event delegation on board containers. |
|
||||||
| `drag.js` | Board and bookmark reordering via Pointer Events API. |
|
| `drag.js` | Board and bookmark reordering via Pointer Events API. |
|
||||||
| `settings.js` | Settings panel UI, toggle handlers, appearance modal, background upload. |
|
| `settings.js` | Settings panel UI, toggle handlers, appearance modal, background upload. |
|
||||||
| `search.js` | Search bar with engine switching (Google, DuckDuckGo, Bing). |
|
| `search.js` | Search bar with engine switching (Google, DuckDuckGo, Bing). |
|
||||||
| `onboarding.js` | Multi-slide first-run flow including the gaming starter board opt-in. |
|
|
||||||
| `widgets.js` | Widget manager — creates DOM, handles drag/resize/z-index, provides registry. See [widget-schema.md](widget-schema.md). |
|
| `widgets.js` | Widget manager — creates DOM, handles drag/resize/z-index, provides registry. See [widget-schema.md](widget-schema.md). |
|
||||||
| `notes.js` | Notes and checklists as widgets. Multi-instance (max 5). Notebook sidebar. Also handles widget toolbar events. |
|
| `notes.js` | Notes and checklists as widgets. Multi-instance (max 5). Notebook sidebar. Also handles widget toolbar events. |
|
||||||
| `calculator.js` | Calculator widget. Single-instance. Shunting-yard expression parser — no `eval()`. |
|
| `calculator.js` | Calculator widget. Single-instance. Shunting-yard expression parser — no `eval()`. |
|
||||||
| `timer.js` | Timer/countdown widget. Single-instance. Presets, Web Audio API alarm, tab-title blink on completion. |
|
| `timer.js` | Timer/countdown widget. Single-instance. Presets, Web Audio API alarm, tab-title blink on completion. |
|
||||||
| `image-ref.js` | Image reference widget. Multi-instance (max 3). Canvas API WebP conversion, sessionStorage for image data — cleared on browser close. |
|
| `image-ref.js` | Image reference widget. Multi-instance (max 3). Canvas API WebP conversion, sessionStorage for image data — cleared on browser close. |
|
||||||
|
| `bookmark-import.js` | Direct browser bookmark import via `chrome.bookmarks.getTree()`. Folder selection modal with duplicate detection. |
|
||||||
| `data.js` | JSON export/import with validation. Covers boards, notes, calculator history and timer presets. |
|
| `data.js` | JSON export/import with validation. Covers boards, notes, calculator history and timer presets. |
|
||||||
|
| `onboarding.js` | Multi-slide first-run flow including the gaming starter board opt-in. |
|
||||||
| `app.js` | Entry point. Calls `init()` on DOMContentLoaded. Clock, global event binding. |
|
| `app.js` | Entry point. Calls `init()` on DOMContentLoaded. Clock, global event binding. |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -106,21 +110,23 @@ DOMContentLoaded
|
|||||||
Scripts are loaded in `newtab.html` in dependency order. A module may only reference modules loaded before it — there is no bundler to handle this automatically.
|
Scripts are loaded in `newtab.html` in dependency order. A module may only reference modules loaded before it — there is no bundler to handle this automatically.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script src="src/js/dialog.js"></script>
|
|
||||||
<script src="src/js/storage.js"></script>
|
<script src="src/js/storage.js"></script>
|
||||||
<script src="src/js/state.js"></script>
|
<script src="src/js/state.js"></script>
|
||||||
|
<script src="src/js/i18n.js"></script>
|
||||||
|
<script src="src/js/dialog.js"></script>
|
||||||
<script src="src/js/themes.js"></script>
|
<script src="src/js/themes.js"></script>
|
||||||
<script src="src/js/boards.js"></script>
|
|
||||||
<script src="src/js/drag.js"></script>
|
<script src="src/js/drag.js"></script>
|
||||||
|
<script src="src/js/boards.js"></script>
|
||||||
<script src="src/js/settings.js"></script>
|
<script src="src/js/settings.js"></script>
|
||||||
<script src="src/js/search.js"></script>
|
<script src="src/js/search.js"></script>
|
||||||
<script src="src/js/onboarding.js"></script>
|
|
||||||
<script src="src/js/widgets.js"></script>
|
<script src="src/js/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/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/data.js"></script>
|
<script src="src/js/data.js"></script>
|
||||||
|
<script src="src/js/onboarding.js"></script>
|
||||||
<script src="src/js/app.js"></script>
|
<script src="src/js/app.js"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,955 @@
|
|||||||
|
# Hellion NewTab v2.0.1 Hardening — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Harden v2.0.0 with security fixes, widget event-system refactoring, i18n completeness, and code quality improvements.
|
||||||
|
|
||||||
|
**Architecture:** Foundation-First — build the new widget event system first, then migrate widget modules onto it, then layer security, i18n, and quality fixes. Each task touches isolated files to avoid merge conflicts.
|
||||||
|
|
||||||
|
**Tech Stack:** Vanilla JavaScript ES2020, CSS Custom Properties, Browser Extension Manifest V3, no build step, no npm.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-16-hardening-v2.0.1-design.md`
|
||||||
|
|
||||||
|
**Testing:** No automated test framework. Each task includes manual browser-based verification steps. Load the extension in Chrome (`chrome://extensions` → Developer mode → Load unpacked) after each task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Tasks | Changes |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/js/widgets.js` | 1, 2 | Add event system (`_emitter`, `on`, `off`), dispatch events in `close`/`minimize`/`openWidget`, replace `setTimeout` with `transitionend` |
|
||||||
|
| `src/js/calculator.js` | 3 | Replace monkey-patching (L692-728) with `WidgetManager.on()` listeners |
|
||||||
|
| `src/js/timer.js` | 3 | Replace monkey-patching (L723-758) with `WidgetManager.on()` listeners |
|
||||||
|
| `src/js/image-ref.js` | 3 | Replace monkey-patching (L463-498) with `WidgetManager.on()` listeners |
|
||||||
|
| `src/js/settings.js` | 4 | Add `isValidBgUrl()`, validate in `applySettings()` and file upload + URL input handlers |
|
||||||
|
| `src/js/data.js` | 5 | Add `isSafeUrl()`, immutable mapping, string length limits, Notes import via `Notes.init()` |
|
||||||
|
| `src/js/state.js` | 6 | Remove `getFaviconUrl()` |
|
||||||
|
| `src/js/boards.js` | 6 | Replace `<img>` favicon with local letter-div |
|
||||||
|
| `src/css/main.css` | 6, 7 | Replace `.bm-favicon`/`.bm-favicon-fallback` with `.bm-favicon-local`, add `@supports not` fallback, add `--bg-solid-fallback` per theme |
|
||||||
|
| `newtab.html` | 8 | Add 5x `data-i18n-title`, 3x `data-i18n` |
|
||||||
|
| `src/js/i18n.js` | 8 | Add 10 new keys to `STRINGS.de` and `STRINGS.en` (8 i18n + 2 bgUrl validation) |
|
||||||
|
| `src/js/app.js` | 9 | Store `setInterval` ID in variable |
|
||||||
|
| `manifest.json` | 9 | Version bump to 2.0.1 |
|
||||||
|
| `manifest.firefox.json` | 9 | Version bump to 2.0.1 |
|
||||||
|
| `manifest.opera.json` | 9 | Version bump to 2.0.1 |
|
||||||
|
| `CHANGELOG.md` | 9 | Add v2.0.1 entry |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Widget Event-System in WidgetManager
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/js/widgets.js:6-10` (add emitter + on/off)
|
||||||
|
- Modify: `src/js/widgets.js:143-148` (close — dispatch event)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add event emitter and on/off methods to WidgetManager**
|
||||||
|
|
||||||
|
In `src/js/widgets.js`, add three new properties after `STORAGE_KEY: 'widgetStates',` (line 10):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/** @type {EventTarget} Internes Event-System fuer Widget-Lifecycle */
|
||||||
|
_emitter: new EventTarget(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-Listener registrieren
|
||||||
|
* @param {string} event - z.B. 'widget:close', 'widget:minimize', 'widget:open'
|
||||||
|
* @param {Function} handler
|
||||||
|
*/
|
||||||
|
on(event, handler) {
|
||||||
|
this._emitter.addEventListener(event, handler);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-Listener entfernen
|
||||||
|
* @param {string} event
|
||||||
|
* @param {Function} handler
|
||||||
|
*/
|
||||||
|
off(event, handler) {
|
||||||
|
this._emitter.removeEventListener(event, handler);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Dispatch `widget:close` event in close()**
|
||||||
|
|
||||||
|
Replace the `close` method (lines 143-148):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
close(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.el.remove();
|
||||||
|
this._widgets.delete(id);
|
||||||
|
this._emitter.dispatchEvent(new CustomEvent('widget:close', { detail: { id } }));
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The event fires AFTER `el.remove()` and `_widgets.delete()`. Listeners must not access the widget entry.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify event system loads without errors**
|
||||||
|
|
||||||
|
Reload the extension in the browser. Open the console (`F12`). Verify:
|
||||||
|
- No JavaScript errors on load
|
||||||
|
- `WidgetManager.on` is a function (type `WidgetManager.on` in console)
|
||||||
|
- `WidgetManager._emitter` is an EventTarget
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/js/widgets.js
|
||||||
|
git commit -m "refactor(widgets): add EventTarget-based lifecycle event system
|
||||||
|
|
||||||
|
Add _emitter, on(), off() to WidgetManager. Dispatch widget:close event
|
||||||
|
after close(). Foundation for removing monkey-patching from widget modules."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Minimize with transitionend + openWidget event dispatch
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/js/widgets.js:154-163` (minimize)
|
||||||
|
- Modify: `src/js/widgets.js:169-180` (openWidget)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace setTimeout with transitionend in minimize()**
|
||||||
|
|
||||||
|
Replace the `minimize` method (lines 154-163):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async minimize(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.state.open = false;
|
||||||
|
entry._minimizing = true;
|
||||||
|
entry.el.classList.add('widget-minimized');
|
||||||
|
|
||||||
|
entry.el.addEventListener('transitionend', function onEnd(e) {
|
||||||
|
if (e.target !== entry.el) return;
|
||||||
|
entry.el.removeEventListener('transitionend', onEnd);
|
||||||
|
if (entry._minimizing) {
|
||||||
|
entry.el.style.display = 'none';
|
||||||
|
}
|
||||||
|
entry._minimizing = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add race-condition guard and event dispatch to openWidget()**
|
||||||
|
|
||||||
|
Replace the `openWidget` method (lines 169-180):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async openWidget(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
entry._minimizing = false;
|
||||||
|
entry.state.open = true;
|
||||||
|
entry.el.style.display = 'flex';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
entry.el.classList.remove('widget-minimized');
|
||||||
|
});
|
||||||
|
this.bringToFront(id);
|
||||||
|
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Key change: `entry._minimizing = false` cancels any in-flight minimize transition.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify minimize/open animation works**
|
||||||
|
|
||||||
|
Reload extension. Test:
|
||||||
|
1. Create a note → minimize it → verify it fades out and disappears
|
||||||
|
2. Click the note in the widget toolbar to reopen → verify it appears smoothly
|
||||||
|
3. Rapid test: minimize → immediately reopen before animation ends → verify no display glitch (the race condition fix)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/js/widgets.js
|
||||||
|
git commit -m "fix(widgets): replace setTimeout with transitionend in minimize
|
||||||
|
|
||||||
|
Fixes race condition where openWidget() during the 250ms timeout would
|
||||||
|
be overridden. Uses _minimizing flag to cancel in-flight transitions.
|
||||||
|
Dispatches widget:minimize and widget:open events."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Migrate Calculator, Timer, ImageRef to Event Listeners
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/js/calculator.js:692-728`
|
||||||
|
- Modify: `src/js/timer.js:723-758`
|
||||||
|
- Modify: `src/js/image-ref.js:463-498`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace monkey-patching in calculator.js**
|
||||||
|
|
||||||
|
Replace lines 692-728 (the three monkey-patching blocks in `init()`) with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Widget-Lifecycle-Events
|
||||||
|
const self = this;
|
||||||
|
WidgetManager.on('widget:close', (e) => {
|
||||||
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
|
self.onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
WidgetManager.on('widget:minimize', (e) => {
|
||||||
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = false;
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
WidgetManager.on('widget:open', (e) => {
|
||||||
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = true;
|
||||||
|
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||||
|
if (body && body.children.length === 0) {
|
||||||
|
self.renderBody(body);
|
||||||
|
}
|
||||||
|
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||||
|
if (entry) self._bindKeyboard(entry.el);
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace monkey-patching in timer.js**
|
||||||
|
|
||||||
|
Replace lines 723-758 (the three monkey-patching blocks in `init()`) with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Widget-Lifecycle-Events
|
||||||
|
const self = this;
|
||||||
|
WidgetManager.on('widget:close', (e) => {
|
||||||
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
|
self.onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
WidgetManager.on('widget:minimize', (e) => {
|
||||||
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = false;
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
WidgetManager.on('widget:open', (e) => {
|
||||||
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = true;
|
||||||
|
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||||
|
if (body && body.children.length === 0) {
|
||||||
|
self.renderBody(body);
|
||||||
|
}
|
||||||
|
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||||
|
if (entry) self._bindKeyboard(entry.el);
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace monkey-patching in image-ref.js**
|
||||||
|
|
||||||
|
Replace lines 463-498 (the three monkey-patching blocks in `init()`) with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Widget-Lifecycle-Events
|
||||||
|
const self = this;
|
||||||
|
WidgetManager.on('widget:close', (e) => {
|
||||||
|
const isImage = self._images.some(img => img.id === e.detail.id);
|
||||||
|
if (isImage) {
|
||||||
|
self.onClose(e.detail.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
WidgetManager.on('widget:minimize', (e) => {
|
||||||
|
const isImage = self._images.some(img => img.id === e.detail.id);
|
||||||
|
if (isImage) {
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
WidgetManager.on('widget:open', (e) => {
|
||||||
|
const imgData = self._images.find(img => img.id === e.detail.id);
|
||||||
|
if (imgData) {
|
||||||
|
const body = WidgetManager.getBody(e.detail.id);
|
||||||
|
if (body && body.children.length === 0) {
|
||||||
|
const dataUrl = self._getSessionImage(e.detail.id);
|
||||||
|
self.renderBody(imgData, body, dataUrl);
|
||||||
|
}
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify all three widget types work**
|
||||||
|
|
||||||
|
Reload extension. Test each widget type:
|
||||||
|
|
||||||
|
1. **Calculator:** Open → type a calculation → minimize → reopen → verify history is still there → close → reopen from toolbar
|
||||||
|
2. **Timer:** Open → set a time → minimize → reopen → verify time is preserved → close
|
||||||
|
3. **Image-Ref:** Enable in Settings → open image widget → add an image → minimize → reopen → verify image displays → close
|
||||||
|
|
||||||
|
Check console for any errors during all operations.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/js/calculator.js src/js/timer.js src/js/image-ref.js
|
||||||
|
git commit -m "refactor(widgets): migrate Calculator, Timer, ImageRef to event listeners
|
||||||
|
|
||||||
|
Replace monkey-patching of WidgetManager.close/minimize/openWidget with
|
||||||
|
WidgetManager.on() event listeners. Eliminates 3-deep closure chain."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Security — URL Validation in settings.js
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/js/settings.js:52-95` (applySettings)
|
||||||
|
- Modify: `src/js/settings.js:166-175` (btnApplyBg handler)
|
||||||
|
- Modify: `src/js/settings.js:181-194` (bgFileInput handler)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add isValidBgUrl() helper**
|
||||||
|
|
||||||
|
Add this function at the top of `settings.js`, after the `closeThemeModal()` function (after line 24):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Prueft ob eine Background-URL sicher fuer CSS-Einbettung ist.
|
||||||
|
* Erlaubt nur blob: und data:image/ Protokolle (aus File Upload).
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isValidBgUrl(url) {
|
||||||
|
return typeof url === 'string' && url.length > 0 &&
|
||||||
|
(url.startsWith('blob:') || url.startsWith('data:image/'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add validation in applySettings()**
|
||||||
|
|
||||||
|
Replace lines 92-94:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (settings.bgUrl) {
|
||||||
|
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
|
||||||
|
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
||||||
|
} else if (settings.bgUrl) {
|
||||||
|
// Ungueltige URL im Storage — bereinigen
|
||||||
|
settings.bgUrl = '';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add validation in the URL-input handler (btnApplyBg)**
|
||||||
|
|
||||||
|
Replace lines 169-175:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
document.getElementById('btnApplyBg').addEventListener('click', async () => {
|
||||||
|
const url = document.getElementById('bgUrlInput').value.trim();
|
||||||
|
settings.bgUrl = url;
|
||||||
|
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
|
||||||
|
await saveSettings();
|
||||||
|
document.getElementById('bgInputRow').classList.add('hidden');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
document.getElementById('btnApplyBg').addEventListener('click', async () => {
|
||||||
|
const url = document.getElementById('bgUrlInput').value.trim();
|
||||||
|
if (url && !isValidBgUrl(url)) {
|
||||||
|
await HellionDialog.alert(t('settings.bg_invalid_url'), { type: 'danger', title: t('settings.bg_invalid_url.title') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.bgUrl = url;
|
||||||
|
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
|
||||||
|
await saveSettings();
|
||||||
|
document.getElementById('bgInputRow').classList.add('hidden');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the file upload handler is already safe**
|
||||||
|
|
||||||
|
Read `settings.js:181-194`. The `FileReader.readAsDataURL(file)` produces a `data:image/...` string, which passes `isValidBgUrl()`. The handler at line 186 sets `settings.bgUrl = ev.target.result` — this is already valid output. No change needed here.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add i18n keys for the validation error dialog**
|
||||||
|
|
||||||
|
These keys will be added in Task 8 together with all other i18n keys. For now, note that we need:
|
||||||
|
- `settings.bg_invalid_url` — "Nur lokale Bilder (Upload) sind als Hintergrund erlaubt." / "Only local images (upload) are allowed as background."
|
||||||
|
- `settings.bg_invalid_url.title` — "Ungültige URL" / "Invalid URL"
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify background upload still works**
|
||||||
|
|
||||||
|
Reload extension. Test:
|
||||||
|
1. Open Theme Modal → upload a local image → verify it displays as background
|
||||||
|
2. Try entering `javascript:alert(1)` in the URL input → verify it's rejected with a dialog
|
||||||
|
3. Reload → verify the uploaded background persists
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/js/settings.js
|
||||||
|
git commit -m "fix(security): validate background URL before CSS injection
|
||||||
|
|
||||||
|
Add isValidBgUrl() that only allows blob: and data:image/ protocols.
|
||||||
|
Applied in applySettings() and the manual URL input handler.
|
||||||
|
Prevents CSS injection via manipulated bgUrl storage values."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Security + Quality — Data Import Hardening
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/js/data.js:33-127`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add isSafeUrl() helper at top of data.js**
|
||||||
|
|
||||||
|
Add after the `initDataButtons` function declaration (after line 6, before the function body):
|
||||||
|
|
||||||
|
Actually, add it inside the function before the event listeners, right after `if (!btnExport || !btnImport) return;` (after line 10):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Prueft ob eine URL ein sicheres Protokoll hat.
|
||||||
|
* Blockiert javascript:, data:, vbscript: etc.
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isSafeUrl(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the mutable board/bookmark filter with immutable mapping**
|
||||||
|
|
||||||
|
Replace lines 41-52 (the `validBoards` filter block):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const validBoards = data.boards
|
||||||
|
.filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
|
||||||
|
.map(b => ({
|
||||||
|
id: b.id || uid(),
|
||||||
|
title: String(b.title).slice(0, 100),
|
||||||
|
blurred: !!b.blurred,
|
||||||
|
bookmarks: b.bookmarks
|
||||||
|
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
|
||||||
|
.map(bm => ({
|
||||||
|
id: bm.id || uid(),
|
||||||
|
title: String(bm.title).slice(0, 200),
|
||||||
|
url: bm.url,
|
||||||
|
desc: String(bm.desc || '').slice(0, 500)
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the mutable notes filter with immutable mapping**
|
||||||
|
|
||||||
|
Replace lines 68-71 (the `importNotes` filter):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const importNotes = data.notes
|
||||||
|
.filter(n => n && n.id && n.template)
|
||||||
|
.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
|
||||||
|
title: String(n.title || '').slice(0, 200),
|
||||||
|
content: String(n.content || '').slice(0, 5000),
|
||||||
|
x: typeof n.x === 'number' ? n.x : 120,
|
||||||
|
y: typeof n.y === 'number' ? n.y : 80,
|
||||||
|
width: typeof n.width === 'number' ? n.width : 280,
|
||||||
|
height: typeof n.height === 'number' ? n.height : 220,
|
||||||
|
open: n.open !== false,
|
||||||
|
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace direct Notes._notes mutation with Notes.init()**
|
||||||
|
|
||||||
|
Replace lines 76-81:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (toImport.length > 0) {
|
||||||
|
const merged = [...existingNotes, ...toImport];
|
||||||
|
existingWidgets.notes = merged;
|
||||||
|
Notes._notes = merged;
|
||||||
|
notesImported = toImport.length;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (toImport.length > 0) {
|
||||||
|
const merged = [...existingNotes, ...toImport];
|
||||||
|
existingWidgets.notes = merged;
|
||||||
|
notesImported = toImport.length;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then after line 113 (`await Store.set('widgetStates', existingWidgets);`), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
|
||||||
|
if (notesImported > 0) await Notes.init();
|
||||||
|
if (calcImported) await Calculator.load();
|
||||||
|
if (timerImported) await Timer.load();
|
||||||
|
```
|
||||||
|
|
||||||
|
And remove the direct mutations at lines 93 and 107:
|
||||||
|
- Remove: `Calculator._history = existingWidgets.calculator.history;` (line 93)
|
||||||
|
- Remove: `Timer._presets = existingWidgets.timer.presets;` (line 107)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify import functionality**
|
||||||
|
|
||||||
|
Reload extension. Test:
|
||||||
|
1. Export current data as JSON
|
||||||
|
2. Edit the exported JSON: add a bookmark with `javascript:alert(1)` URL → import → verify the bad bookmark is silently skipped
|
||||||
|
3. Import a normal JSON backup → verify boards, notes, calculator history, timer presets all appear correctly
|
||||||
|
4. Verify no console errors
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/js/data.js
|
||||||
|
git commit -m "fix(security): harden JSON import with URL validation and immutable mapping
|
||||||
|
|
||||||
|
Add isSafeUrl() to block javascript:/data: URLs in imported bookmarks.
|
||||||
|
Replace mutable object mutation with immutable .map() and string length limits.
|
||||||
|
Use Notes.init()/Calculator.load()/Timer.load() instead of direct _notes/_history
|
||||||
|
mutation after import."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Remove Google Favicons — Local Letter Icons
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/js/state.js:36-43` (remove `getFaviconUrl`)
|
||||||
|
- Modify: `src/js/boards.js:218-230` (replace favicon rendering)
|
||||||
|
- Modify: `src/css/main.css:565-571` (replace CSS classes)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove getFaviconUrl() from state.js**
|
||||||
|
|
||||||
|
Delete lines 36-43 in `src/js/state.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function getFaviconUrl(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=16`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace favicon rendering in boards.js**
|
||||||
|
|
||||||
|
Replace lines 218-230 in `src/js/boards.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const favicon = document.createElement('img');
|
||||||
|
favicon.className = 'bm-favicon';
|
||||||
|
favicon.width = 14;
|
||||||
|
favicon.height = 14;
|
||||||
|
favicon.src = getFaviconUrl(bm.url);
|
||||||
|
favicon.addEventListener('error', function() {
|
||||||
|
this.classList.add('hidden');
|
||||||
|
this.nextElementSibling.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallback = document.createElement('div');
|
||||||
|
fallback.className = 'bm-favicon-fallback hidden';
|
||||||
|
fallback.textContent = bm.title.charAt(0).toUpperCase();
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const favicon = document.createElement('div');
|
||||||
|
favicon.className = 'bm-favicon-local';
|
||||||
|
favicon.textContent = bm.title.charAt(0).toUpperCase();
|
||||||
|
const hue = (bm.title.charCodeAt(0) * 137) % 360;
|
||||||
|
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the `appendChild` calls below. The old code appends both `favicon` and `fallback`:
|
||||||
|
|
||||||
|
Find the line that appends the fallback (should be near line 243-244):
|
||||||
|
```javascript
|
||||||
|
li.append(favicon, fallback, textDiv, deleteBtn);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```javascript
|
||||||
|
li.append(favicon, textDiv, deleteBtn);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace CSS classes in main.css**
|
||||||
|
|
||||||
|
Replace lines 565-571:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.bm-favicon { width: 14px; height: 14px; flex-shrink: 0; border-radius: 2px; opacity: 0.85; }
|
||||||
|
.bm-favicon-fallback {
|
||||||
|
width: 14px; height: 14px; flex-shrink: 0;
|
||||||
|
background: var(--accent-dim); border-radius: 2px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 8px; color: var(--accent);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.bm-favicon-local {
|
||||||
|
width: 16px; height: 16px; flex-shrink: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 9px; font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify favicons display correctly**
|
||||||
|
|
||||||
|
Reload extension. Check:
|
||||||
|
1. All bookmarks show a colored letter icon
|
||||||
|
2. Different bookmark titles produce different colors
|
||||||
|
3. The icons are aligned and properly sized in all themes
|
||||||
|
4. No network requests to google.com in the Network tab (F12 → Network)
|
||||||
|
5. No console errors about `getFaviconUrl`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/js/state.js src/js/boards.js src/css/main.css
|
||||||
|
git commit -m "feat(privacy): replace Google Favicons with local letter icons
|
||||||
|
|
||||||
|
Remove getFaviconUrl() and all external network requests. Bookmarks now
|
||||||
|
show a colored letter icon with deterministic hue based on title.
|
||||||
|
Eliminates privacy leak and Brave Shields compatibility issues."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: backdrop-filter Fallback for Brave Shields
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/css/main.css` (add `--bg-solid-fallback` per theme + `@supports not` block)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add --bg-solid-fallback to each theme**
|
||||||
|
|
||||||
|
Add the variable to each theme's `[data-theme]` block. The value is an opaque version of `--bg-board`:
|
||||||
|
|
||||||
|
| Theme | Line | `--bg-solid-fallback` value |
|
||||||
|
|---|---|---|
|
||||||
|
| nebula | ~82 | `#0a060e` |
|
||||||
|
| crescent | ~108 | `#0c0b08` |
|
||||||
|
| event-horizon | ~137 | `#06040f` |
|
||||||
|
| merchantman | ~163 | `#040d0d` |
|
||||||
|
| julia-jin | ~189 | `#080c12` |
|
||||||
|
| sc-sunset | ~216 | `#0e0808` |
|
||||||
|
| hellion-hud | ~245 | `#04080c` |
|
||||||
|
| hellion-energy | ~278 | `#040a08` |
|
||||||
|
| satisfactory | ~310 | `#060a0c` |
|
||||||
|
| avorion | ~341 | `#040c0a` |
|
||||||
|
| hellion-stealth | ~371 | `#060a0e` |
|
||||||
|
|
||||||
|
Add `--bg-solid-fallback: <value>;` as the last variable in each theme block.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add @supports not block at the end of the general layout section**
|
||||||
|
|
||||||
|
Add after the existing board/widget styles, before the theme-specific sections (around line 75, before the first `[data-theme]` block):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Fallback fuer Browser die backdrop-filter blockieren (z.B. Brave Shields) */
|
||||||
|
@supports not (backdrop-filter: blur(1px)) {
|
||||||
|
.board,
|
||||||
|
.widget,
|
||||||
|
.settings-panel,
|
||||||
|
.dialog-box,
|
||||||
|
.theme-modal,
|
||||||
|
.search-bar {
|
||||||
|
background-color: var(--bg-solid-fallback, var(--bg-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify fallback works**
|
||||||
|
|
||||||
|
Test in Brave with Shields set to aggressive. Or test by temporarily adding this CSS rule:
|
||||||
|
```css
|
||||||
|
.board { backdrop-filter: none !important; }
|
||||||
|
```
|
||||||
|
Verify that boards still have a visible background (opaque, not transparent).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/css/main.css
|
||||||
|
git commit -m "fix(compat): add backdrop-filter fallback for Brave Shields
|
||||||
|
|
||||||
|
Add --bg-solid-fallback CSS variable to all 11 themes and a
|
||||||
|
@supports not (backdrop-filter) block. UI remains usable when
|
||||||
|
Brave Shields or strict fingerprinting settings block backdrop-filter."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Complete i18n Coverage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `newtab.html:26-42` (add `data-i18n-title` to 5 header buttons)
|
||||||
|
- Modify: `newtab.html:198, 215, 374` (add `data-i18n` to 3 setting buttons)
|
||||||
|
- Modify: `src/js/i18n.js` (add 10 new keys — 8 from spec + 2 from Task 4)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add data-i18n-title to header buttons in newtab.html**
|
||||||
|
|
||||||
|
Line 26 — change:
|
||||||
|
```html
|
||||||
|
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)">
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```html
|
||||||
|
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)" data-i18n-title="header.import_title">
|
||||||
|
```
|
||||||
|
|
||||||
|
Line 30 — change:
|
||||||
|
```html
|
||||||
|
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen">
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```html
|
||||||
|
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen" data-i18n-title="header.board_title">
|
||||||
|
```
|
||||||
|
|
||||||
|
Line 34 — change:
|
||||||
|
```html
|
||||||
|
<button class="btn-icon" id="btnNote" title="Schnellnotiz">
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```html
|
||||||
|
<button class="btn-icon" id="btnNote" title="Schnellnotiz" data-i18n-title="header.note_title">
|
||||||
|
```
|
||||||
|
|
||||||
|
Line 38 — change:
|
||||||
|
```html
|
||||||
|
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme">
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```html
|
||||||
|
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme" data-i18n-title="header.theme_title">
|
||||||
|
```
|
||||||
|
|
||||||
|
Line 42 — change:
|
||||||
|
```html
|
||||||
|
<button class="btn-icon" id="btnSettings" title="Einstellungen">
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```html
|
||||||
|
<button class="btn-icon" id="btnSettings" title="Einstellungen" data-i18n-title="header.settings_title">
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add data-i18n to settings buttons in newtab.html**
|
||||||
|
|
||||||
|
Line 198 — change:
|
||||||
|
```html
|
||||||
|
<button class="btn-small" id="btnRestartOnboarding">Start</button>
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```html
|
||||||
|
<button class="btn-small" id="btnRestartOnboarding" data-i18n="settings.onboarding_btn">Start</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Line 215 — change:
|
||||||
|
```html
|
||||||
|
<button class="btn-danger" id="btnResetAll">Reset</button>
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```html
|
||||||
|
<button class="btn-danger" id="btnResetAll" data-i18n="settings.reset_btn">Reset</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Line 374 — change:
|
||||||
|
```html
|
||||||
|
<button class="btn-small" id="btnBgFile">Upload</button>
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```html
|
||||||
|
<button class="btn-small" id="btnBgFile" data-i18n="settings.bg_upload_btn">Upload</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add new keys to STRINGS.de in i18n.js**
|
||||||
|
|
||||||
|
Add these keys to the `STRINGS.de` object, in the appropriate sections:
|
||||||
|
|
||||||
|
In the Header section:
|
||||||
|
```javascript
|
||||||
|
'header.import_title': 'Bookmarks importieren (HTML)',
|
||||||
|
'header.board_title': 'Neues Board hinzufügen',
|
||||||
|
'header.note_title': 'Schnellnotiz',
|
||||||
|
'header.theme_title': 'Darstellung & Theme',
|
||||||
|
'header.settings_title': 'Einstellungen',
|
||||||
|
```
|
||||||
|
|
||||||
|
In the Settings section:
|
||||||
|
```javascript
|
||||||
|
'settings.onboarding_btn': 'Start',
|
||||||
|
'settings.reset_btn': 'Reset',
|
||||||
|
'settings.bg_upload_btn': 'Upload',
|
||||||
|
'settings.bg_invalid_url': 'Nur lokale Bilder (Upload) sind als Hintergrund erlaubt.',
|
||||||
|
'settings.bg_invalid_url.title': 'Ungültige URL',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add new keys to STRINGS.en in i18n.js**
|
||||||
|
|
||||||
|
Add the matching English keys to `STRINGS.en`:
|
||||||
|
|
||||||
|
In the Header section:
|
||||||
|
```javascript
|
||||||
|
'header.import_title': 'Import bookmarks (HTML)',
|
||||||
|
'header.board_title': 'Add new board',
|
||||||
|
'header.note_title': 'Quick note',
|
||||||
|
'header.theme_title': 'Appearance & Theme',
|
||||||
|
'header.settings_title': 'Settings',
|
||||||
|
```
|
||||||
|
|
||||||
|
In the Settings section:
|
||||||
|
```javascript
|
||||||
|
'settings.onboarding_btn': 'Start',
|
||||||
|
'settings.reset_btn': 'Reset',
|
||||||
|
'settings.bg_upload_btn': 'Upload',
|
||||||
|
'settings.bg_invalid_url': 'Only local images (upload) are allowed as background.',
|
||||||
|
'settings.bg_invalid_url.title': 'Invalid URL',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify translations**
|
||||||
|
|
||||||
|
Reload extension. Test:
|
||||||
|
1. Set language to English → hover over header buttons → verify English tooltips
|
||||||
|
2. Set language to German → hover → verify German tooltips
|
||||||
|
3. Open Settings → verify "Start", "Reset", "Upload" buttons have `data-i18n` attributes (inspect in DevTools)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add newtab.html src/js/i18n.js
|
||||||
|
git commit -m "fix(i18n): complete missing translations for toolbar tooltips and button texts
|
||||||
|
|
||||||
|
Add data-i18n-title to 5 header buttons, data-i18n to 3 settings buttons.
|
||||||
|
Add 10 new keys to STRINGS.de and STRINGS.en including background URL
|
||||||
|
validation error messages."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Version Bump, Changelog, Clock Cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/js/app.js:135`
|
||||||
|
- Modify: `manifest.json:5`
|
||||||
|
- Modify: `manifest.firefox.json` (version field)
|
||||||
|
- Modify: `manifest.opera.json` (version field)
|
||||||
|
- Modify: `CHANGELOG.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Store clock interval ID in app.js**
|
||||||
|
|
||||||
|
Replace line 135 in `src/js/app.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const clockInterval = setInterval(tick, 1000);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Bump version in all three manifests**
|
||||||
|
|
||||||
|
In `manifest.json`, `manifest.firefox.json`, and `manifest.opera.json`, change:
|
||||||
|
```json
|
||||||
|
"version": "2.0.0",
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```json
|
||||||
|
"version": "2.0.1",
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add CHANGELOG entry**
|
||||||
|
|
||||||
|
Add this block at the top of `CHANGELOG.md`, after the header and before the v2.0.0 entry:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### v2.0.1 — 16.04.2026
|
||||||
|
|
||||||
|
#### Security
|
||||||
|
|
||||||
|
- **Background URL validation** — Only `blob:` and `data:image/` protocols allowed in CSS `backgroundImage` (prevents CSS injection via manipulated storage)
|
||||||
|
- **Import URL validation** — `javascript:`, `data:`, and other unsafe protocols are blocked during JSON import
|
||||||
|
- **Immutable import mapping** — Imported boards, bookmarks, and notes are sanitized with explicit field selection and string length limits
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- **Widget minimize race condition** — Replaced `setTimeout` with `transitionend` event; `openWidget()` during animation no longer causes display glitch
|
||||||
|
- **Notes import mutation** — Import now uses `Notes.init()` instead of directly setting `Notes._notes`
|
||||||
|
- **Complete i18n coverage** — 5 header button tooltips and 3 settings button texts now have `data-i18n` attributes (10 new translation keys)
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- **Widget event system** — `WidgetManager` now dispatches `widget:close`, `widget:minimize`, `widget:open` CustomEvents via `EventTarget`. Calculator, Timer, and ImageRef use `WidgetManager.on()` instead of monkey-patching
|
||||||
|
- **Local favicon icons** — Replaced Google Favicons API with local colored letter icons (deterministic hue per title). Zero external network requests, Brave Shields compatible
|
||||||
|
- **backdrop-filter fallback** — `@supports not (backdrop-filter)` block with `--bg-solid-fallback` per theme for Brave Shields compatibility
|
||||||
|
- **Clock interval cleanup** — `setInterval` ID stored in variable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify everything**
|
||||||
|
|
||||||
|
Full manual test:
|
||||||
|
1. Reload extension
|
||||||
|
2. Verify version in `chrome://extensions` shows 2.0.1
|
||||||
|
3. Open/close/minimize/reopen widgets of all types
|
||||||
|
4. Switch language DE/EN — all tooltips translate
|
||||||
|
5. Import/export JSON data
|
||||||
|
6. Upload background image
|
||||||
|
7. Check Network tab — zero external requests
|
||||||
|
8. Check Console — zero errors
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/js/app.js manifest.json manifest.firefox.json manifest.opera.json CHANGELOG.md
|
||||||
|
git commit -m "chore(release): bump version to v2.0.1 — hardening release
|
||||||
|
|
||||||
|
Security fixes, widget event system, local favicons, i18n completeness,
|
||||||
|
backdrop-filter fallback, code quality improvements. See CHANGELOG.md."
|
||||||
|
```
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
# Hellion NewTab — Calculator Upgrade Design
|
||||||
|
|
||||||
|
**Datum:** 2026-04-16
|
||||||
|
**Autor:** Florian Wathling / Claude Code
|
||||||
|
**Status:** Approved
|
||||||
|
**Scope:** Calculator erweitern um Scientific, Unit-Converter und Game-Rechner (Satisfactory, Factorio, Stationeers)
|
||||||
|
**Ziel-Version:** v2.1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Der Calculator ist aktuell ein reiner Grundrechenarten-Taschenrechner (720 Zeilen, Shunting-Yard Parser, 4x5 Button-Grid, History). Das Upgrade macht ihn zum zentralen Tool-Widget mit 6 Modi:
|
||||||
|
|
||||||
|
1. **Standard** (bestehend)
|
||||||
|
2. **Scientific** (Wurzel, Potenz, Pi, Formel-Helfer)
|
||||||
|
3. **Unit-Converter** (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche)
|
||||||
|
4. **Satisfactory** (Items/Min, Overclock-Power, Maschinen-Rechner)
|
||||||
|
5. **Factorio** (Assembler-Ratios, Belt-Throughput, Maschinen-Rechner)
|
||||||
|
6. **Stationeers** (Idealgas, Furnace/Verbrennung, Solar/Batterie, Atmosphäre)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 1: Architektur und Dateistruktur
|
||||||
|
|
||||||
|
### Datei-Aufteilung
|
||||||
|
|
||||||
|
```
|
||||||
|
src/js/
|
||||||
|
├── calculator.js # Core: Tab-System, Standard-Modus, erweiterter Shunting-Yard Parser
|
||||||
|
├── calc-scientific.js # Scientific-Modus
|
||||||
|
├── calc-converter.js # Unit-Converter
|
||||||
|
├── calc-satisfactory.js # Satisfactory Calculator
|
||||||
|
├── calc-factorio.js # Factorio Calculator
|
||||||
|
└── calc-stationeers.js # Stationeers Calculator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Load-Order in newtab.html
|
||||||
|
|
||||||
|
```
|
||||||
|
... → widgets.js → notes.js → calculator.js → calc-scientific.js → calc-converter.js →
|
||||||
|
calc-satisfactory.js → calc-factorio.js → calc-stationeers.js → timer.js → ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle Mode-Dateien laden nach `calculator.js` und vor `timer.js`. Kein zirkulärer Dependency-Konflikt.
|
||||||
|
|
||||||
|
### Registrierungs-Pattern
|
||||||
|
|
||||||
|
Jede Mode-Datei registriert sich beim Calculator-Objekt:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
Calculator.registerMode('scientific', {
|
||||||
|
label: '\uD83D\uDCD0', // Icon
|
||||||
|
shortName: 'Sci', // Tab-Label (3 Zeichen)
|
||||||
|
titleKey: 'calculator.tab.scientific', // i18n-Key
|
||||||
|
render(bodyEl) { /* UI aufbauen */ },
|
||||||
|
destroy() { /* Cleanup, Event-Listener entfernen */ }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`calculator.js` bekommt:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_modes: new Map(),
|
||||||
|
_activeMode: 'standard',
|
||||||
|
|
||||||
|
registerMode(name, config) {
|
||||||
|
this._modes.set(name, config);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Tab-Leiste wird dynamisch aus `_modes` gebaut. Standard-Modus ist immer registriert (intern, nicht per externer Datei). Die anderen Modi kommen dazu wenn ihre Script-Datei geladen ist.
|
||||||
|
|
||||||
|
### Tab-Wechsel
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
switchMode(name) {
|
||||||
|
const mode = this._modes.get(name);
|
||||||
|
if (!mode) return;
|
||||||
|
this._activeMode = name;
|
||||||
|
const body = WidgetManager.getBody(this.WIDGET_ID);
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
// Alten Modus aufräumen
|
||||||
|
const oldMode = this._modes.get(this._previousMode);
|
||||||
|
if (oldMode && oldMode.destroy) oldMode.destroy();
|
||||||
|
|
||||||
|
// Neuen Modus rendern
|
||||||
|
body.textContent = '';
|
||||||
|
mode.render(body);
|
||||||
|
|
||||||
|
// Tab-UI aktualisieren
|
||||||
|
this._updateTabBar();
|
||||||
|
|
||||||
|
// State speichern
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
Jeder Modus speichert seinen State als Sub-Key unter `calculator` im bestehenden `widgetStates`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
calculator: {
|
||||||
|
x: 400, y: 120, width: 320, height: 480,
|
||||||
|
open: true,
|
||||||
|
activeMode: 'standard',
|
||||||
|
history: [{ expr: '42 × 7', result: '294' }],
|
||||||
|
converter: { lastCategory: 'length', fromUnit: 'cm', toUnit: 'in' },
|
||||||
|
satisfactory: { lastSubMode: 'itemsPerMin' },
|
||||||
|
factorio: { lastSubMode: 'ratio', lastAssembler: 'asm3' },
|
||||||
|
stationeers: { lastSubMode: 'gas' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Read-before-write Pattern bleibt: `const data = await Store.get(this.STORAGE_KEY) || {};`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 2: Standard-Modus (Änderungen)
|
||||||
|
|
||||||
|
### Parser-Erweiterung
|
||||||
|
|
||||||
|
Der Shunting-Yard Parser wird um zwei Operationen erweitert:
|
||||||
|
|
||||||
|
**Potenz-Operator `^`:**
|
||||||
|
- Binärer Operator mit höchster Precedence (über `*` und `/`)
|
||||||
|
- Rechts-assoziativ: `2^3^2` = `2^(3^2)` = 512
|
||||||
|
- Tokenizer erkennt `^` als `{ type: 'op', value: '^' }`
|
||||||
|
- parseFactor() → parsePower() → parseFactor() (neue Precedence-Stufe)
|
||||||
|
|
||||||
|
**Wurzel-Funktion `sqrt`:**
|
||||||
|
- Wird vom Scientific-Modus als `sqrt(` in die Expression eingefügt
|
||||||
|
- Tokenizer erkennt `sqrt` als `{ type: 'func', value: 'sqrt' }`
|
||||||
|
- parseFactor() prüft auf Functions vor Numbers
|
||||||
|
|
||||||
|
Die bestehende Operator-Hierarchie wird:
|
||||||
|
```
|
||||||
|
parseExpr: + -
|
||||||
|
parseTerm: * / %
|
||||||
|
parsePower: ^ ← NEU
|
||||||
|
parseFactor: number | (expr) | func(expr) ← func NEU
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keine Änderungen am Standard-UI
|
||||||
|
|
||||||
|
Das 4x5 Button-Grid, History-Panel und Keyboard-Support bleiben identisch. Die Parser-Erweiterung ist rückwärtskompatibel (keine bestehende Expression bricht).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 3: Scientific-Modus
|
||||||
|
|
||||||
|
### Zusätzliche Buttons
|
||||||
|
|
||||||
|
2 neue Reihen über dem Standard-Grid:
|
||||||
|
|
||||||
|
| Button | Wert | Aktion |
|
||||||
|
|---|---|---|
|
||||||
|
| √ | `sqrt(` | Unäre Funktion, öffnet Klammer |
|
||||||
|
| x² | `^2` | Hängt `^2` an Expression |
|
||||||
|
| xⁿ | `^` | Fügt Potenz-Operator ein |
|
||||||
|
| π | `3.14159265359` | Konstante einfügen |
|
||||||
|
| e | `2.71828182846` | Konstante einfügen |
|
||||||
|
| ± | toggle | Vorzeichen des letzten Werts wechseln |
|
||||||
|
|
||||||
|
Darunter das Standard 4x5-Grid (C, Klammern, %, ÷, 0-9, Operatoren, =). Der Scientific-Modus nutzt den gleichen `_handleKey()`/`_calculate()`-Flow.
|
||||||
|
|
||||||
|
### Formel-Helfer
|
||||||
|
|
||||||
|
Ein Dropdown unter dem Button-Grid mit vorgefertigten Formeln:
|
||||||
|
|
||||||
|
| Formel | Eingabefelder | Berechnung |
|
||||||
|
|---|---|---|
|
||||||
|
| Kreis-Fläche | Radius (r) | `π × r²` |
|
||||||
|
| Kreis-Umfang | Radius (r) | `2 × π × r` |
|
||||||
|
| °C → °F | Temperatur | `(C × 9/5) + 32` |
|
||||||
|
| °F → °C | Temperatur | `(F - 32) × 5/9` |
|
||||||
|
| Pythagoras | a, b | `√(a² + b²)` |
|
||||||
|
| Prozent-Wert | Wert, Prozent | `Wert × Prozent / 100` |
|
||||||
|
|
||||||
|
Jede Formel öffnet inline Eingabefelder + Live-Ergebnis. Nutzt `_formatResult()` für einheitliche Zahlenformatierung.
|
||||||
|
|
||||||
|
### Keyboard
|
||||||
|
|
||||||
|
Gleicher Keyboard-Support wie Standard-Modus, plus:
|
||||||
|
- `p` → Pi einfügen
|
||||||
|
- `e` → Euler einfügen (kein Konflikt: `e` ist im Standard nicht belegt, nur `c`/`C` und `Escape` sind Clear)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 4: Unit-Converter
|
||||||
|
|
||||||
|
### UI-Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ [Kategorie-Dropdown ▼]│
|
||||||
|
│ │
|
||||||
|
│ [123.45 ] [cm ▼] │
|
||||||
|
│ ⇅ (Swap-Button) │
|
||||||
|
│ [48.622 ] [in ▼] │
|
||||||
|
│ │
|
||||||
|
│ Schnellreferenz: │
|
||||||
|
│ 1 cm = 0.3937 in │
|
||||||
|
│ 1 in = 2.54 cm │
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kategorien und Einheiten
|
||||||
|
|
||||||
|
| Kategorie | Einheiten | Basis-Einheit |
|
||||||
|
|---|---|---|
|
||||||
|
| Länge | mm, cm, m, km, in, ft, yd, mi | m |
|
||||||
|
| Gewicht | mg, g, kg, t, oz, lb | g |
|
||||||
|
| Temperatur | °C, °F, K | (Spezialfunktionen) |
|
||||||
|
| Volumen | ml, L, m³, gal(US), gal(UK), ft³ | ml |
|
||||||
|
| Geschwindigkeit | m/s, km/h, mph, kn | m/s |
|
||||||
|
| Fläche | mm², cm², m², km², ha, acre, ft², in² | m² |
|
||||||
|
|
||||||
|
### Konvertierungs-Logik
|
||||||
|
|
||||||
|
Jede Einheit hat `toBase(value)` und `fromBase(value)`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const LENGTH_UNITS = {
|
||||||
|
mm: { toBase: v => v / 1000, fromBase: v => v * 1000 },
|
||||||
|
cm: { toBase: v => v / 100, fromBase: v => v * 100 },
|
||||||
|
m: { toBase: v => v, fromBase: v => v },
|
||||||
|
km: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||||
|
in: { toBase: v => v * 0.0254, fromBase: v => v / 0.0254 },
|
||||||
|
ft: { toBase: v => v * 0.3048, fromBase: v => v / 0.3048 },
|
||||||
|
yd: { toBase: v => v * 0.9144, fromBase: v => v / 0.9144 },
|
||||||
|
mi: { toBase: v => v * 1609.344, fromBase: v => v / 1609.344 }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Temperatur bekommt eigene Funktionen (nicht linear):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const TEMP_CONVERSIONS = {
|
||||||
|
'C_F': v => (v * 9/5) + 32,
|
||||||
|
'C_K': v => v + 273.15,
|
||||||
|
'F_C': v => (v - 32) * 5/9,
|
||||||
|
'F_K': v => (v - 32) * 5/9 + 273.15,
|
||||||
|
'K_C': v => v - 273.15,
|
||||||
|
'K_F': v => (v - 273.15) * 9/5 + 32
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verhalten
|
||||||
|
|
||||||
|
- Live-Update bei Eingabe (kein "Berechnen"-Button)
|
||||||
|
- Swap-Button (⇅) tauscht Quell- und Ziel-Einheit
|
||||||
|
- Schnellreferenz zeigt `1 [from] = x [to]` und umgekehrt
|
||||||
|
- Kein Keyboard-Override (native `<input>` Felder)
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
converter: { lastCategory: 'length', fromUnit: 'cm', toUnit: 'in' }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 5: Satisfactory Calculator
|
||||||
|
|
||||||
|
### Sub-Modi
|
||||||
|
|
||||||
|
Drei Buttons oben wählen den aktiven Rechner:
|
||||||
|
|
||||||
|
#### 5a: Items/Min
|
||||||
|
|
||||||
|
**Eingabefelder:**
|
||||||
|
- Items per Craft (default: 1)
|
||||||
|
- Craft Time in Sekunden (default: 4)
|
||||||
|
- Clock Speed in % (default: 100)
|
||||||
|
|
||||||
|
**Formel:**
|
||||||
|
```
|
||||||
|
Output = (ItemsPerCraft × 60) / CraftTime × (ClockSpeed / 100)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ausgabe:** `X.XX items/min`
|
||||||
|
|
||||||
|
#### 5b: Overclock Power
|
||||||
|
|
||||||
|
**Eingabefelder:**
|
||||||
|
- Base Power in MW (default: 30)
|
||||||
|
- Clock Speed in % (default: 100)
|
||||||
|
|
||||||
|
**Formeln:**
|
||||||
|
```
|
||||||
|
PowerUsage = BasePower × (ClockSpeed / 100) ^ 1.321928
|
||||||
|
EnergyPerItem = (ClockSpeed / 100) ^ 0.321928
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ausgabe:**
|
||||||
|
- `Power Usage: X.X MW`
|
||||||
|
- `Efficiency: ↓ X.X% per item` (nur bei ClockSpeed > 100)
|
||||||
|
|
||||||
|
#### 5c: Maschinen
|
||||||
|
|
||||||
|
**Eingabefelder:**
|
||||||
|
- Target Output/Min (default: 60)
|
||||||
|
- Items per Craft (default: 1)
|
||||||
|
- Craft Time in Sekunden (default: 4)
|
||||||
|
- Clock Speed in % (default: 100)
|
||||||
|
- Base Power in MW (default: 30)
|
||||||
|
|
||||||
|
**Formeln:**
|
||||||
|
```
|
||||||
|
ItemsPerMin = (ItemsPerCraft × 60) / CraftTime × (ClockSpeed / 100)
|
||||||
|
Machines = ceil(TargetOutput / ItemsPerMin)
|
||||||
|
TotalPower = Machines × BasePower × (ClockSpeed / 100) ^ 1.321928
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ausgabe:**
|
||||||
|
- `Machines needed: X`
|
||||||
|
- `Total Power: X.X MW`
|
||||||
|
|
||||||
|
### Verhalten
|
||||||
|
|
||||||
|
Alle Felder berechnen live. `<input type="number">` mit `step`-Attribut für sinnvolle Schrittweiten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 6: Factorio Calculator
|
||||||
|
|
||||||
|
### Sub-Modi
|
||||||
|
|
||||||
|
#### 6a: Assembler-Ratio
|
||||||
|
|
||||||
|
**Eingabefelder:**
|
||||||
|
- Assembler-Dropdown: Assembler 1 (0.5), Assembler 2 (0.75), Assembler 3 (1.25)
|
||||||
|
- Recipe Output Count (default: 1)
|
||||||
|
- Recipe Time in Sekunden (default: 1)
|
||||||
|
|
||||||
|
**Formel:**
|
||||||
|
```
|
||||||
|
OutputPerSecond = RecipeOutput × CraftingSpeed / RecipeTime
|
||||||
|
OutputPerMinute = OutputPerSecond × 60
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ausgabe:**
|
||||||
|
- `X.XX items/s`
|
||||||
|
- `X.XX items/min`
|
||||||
|
|
||||||
|
#### 6b: Belt-Throughput
|
||||||
|
|
||||||
|
**Eingabefelder:**
|
||||||
|
- Belt-Dropdown: Yellow (15/s), Red (30/s), Blue (45/s)
|
||||||
|
- Items consumed per second per machine (default: 1)
|
||||||
|
|
||||||
|
**Feste Werte:**
|
||||||
|
|
||||||
|
| Belt | Total (items/s) | Per Side (items/s) |
|
||||||
|
|---|---|---|
|
||||||
|
| Yellow | 15 | 7.5 |
|
||||||
|
| Red | 30 | 15 |
|
||||||
|
| Blue | 45 | 22.5 |
|
||||||
|
|
||||||
|
**Formel:**
|
||||||
|
```
|
||||||
|
MachinesPerBelt = floor(BeltThroughput / ItemsConsumedPerSec)
|
||||||
|
Utilization = (ItemsConsumedPerSec × MachinesPerBelt) / BeltThroughput × 100
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ausgabe:**
|
||||||
|
- `Machines per belt: X`
|
||||||
|
- `Belt utilization: X%`
|
||||||
|
|
||||||
|
#### 6c: Maschinen
|
||||||
|
|
||||||
|
**Eingabefelder:**
|
||||||
|
- Assembler-Dropdown
|
||||||
|
- Target Output/s (default: 10)
|
||||||
|
- Recipe Output Count (default: 1)
|
||||||
|
- Recipe Time in Sekunden (default: 1)
|
||||||
|
|
||||||
|
**Formel:**
|
||||||
|
```
|
||||||
|
OutputPerMachine = RecipeOutput × CraftingSpeed / RecipeTime
|
||||||
|
Machines = ceil(TargetOutput / OutputPerMachine)
|
||||||
|
TotalThroughput = Machines × OutputPerMachine
|
||||||
|
BeltNeeded = kleinster Belt der TotalThroughput schafft
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ausgabe:**
|
||||||
|
- `Machines needed: X`
|
||||||
|
- `Belt needed: [Color] (X% utilization)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 7: Stationeers Calculator
|
||||||
|
|
||||||
|
### Sub-Modi
|
||||||
|
|
||||||
|
Vier Buttons oben (statt drei wie bei den anderen Game-Rechnern).
|
||||||
|
|
||||||
|
#### 7a: Gas (Idealgas PV=nRT)
|
||||||
|
|
||||||
|
**Eingabefelder:**
|
||||||
|
- Dropdown: Gesucht = P, V, n oder T
|
||||||
|
- Die drei anderen Variablen als Eingabefelder
|
||||||
|
|
||||||
|
**Konstante:** R = 8314.46261815324 (Stationeers-spezifisch, Einheit: L·Pa / mol·K)
|
||||||
|
|
||||||
|
**Formeln:**
|
||||||
|
```
|
||||||
|
P = nRT / V
|
||||||
|
V = nRT / P
|
||||||
|
n = PV / RT
|
||||||
|
T = PV / nR
|
||||||
|
```
|
||||||
|
|
||||||
|
**Eingabe-Einheiten:**
|
||||||
|
- P in kPa (wird intern × 1000 zu Pa)
|
||||||
|
- V in Litern
|
||||||
|
- T in Kelvin (Hilfstext zeigt °C-Äquivalent)
|
||||||
|
- n in mol
|
||||||
|
|
||||||
|
#### 7b: Furnace / Verbrennung
|
||||||
|
|
||||||
|
**Eingabefelder:**
|
||||||
|
- Fuel Ratio (0 bis 1, Anteil Brennstoff am Gesamtgas)
|
||||||
|
- Start-Temperatur in Kelvin
|
||||||
|
- Start-Druck in kPa
|
||||||
|
|
||||||
|
**Formeln:**
|
||||||
|
```
|
||||||
|
T_nach = (T_vor × specificHeat + fuel × 563452) / (specificHeat + fuel × 172.615)
|
||||||
|
P_nach = P_vor × T_nach × (1 + 5.7 × fuel) / T_vor
|
||||||
|
```
|
||||||
|
|
||||||
|
Wobei:
|
||||||
|
- `fuel = min(ratioO2, ratioVolatile / 2)`
|
||||||
|
- `specificHeat` = gewichtete Summe der Gas-Wärmekapazitäten
|
||||||
|
- Vereinfachung: Fuel Ratio als einzelner Wert (0-1), `specificHeat(before)` wird aus reinem Fuel berechnet (61.9 J/mol·K für 1:2 O₂:H₂ Mischung)
|
||||||
|
- 563452 = Energie pro Mol bei 95% Effizienz
|
||||||
|
- 172.615 = 0.95 × (243.6 - 61.9)
|
||||||
|
|
||||||
|
**Validierung:**
|
||||||
|
- Warnung wenn Fuel < 0.05 (unter 5% Minimum)
|
||||||
|
- Warnung wenn Start-Druck < 10 kPa
|
||||||
|
|
||||||
|
**Ausgabe:**
|
||||||
|
- `T after ignition: X K (X °C)`
|
||||||
|
- `P after ignition: X kPa`
|
||||||
|
|
||||||
|
#### 7c: Solar / Batterie
|
||||||
|
|
||||||
|
**Eingabefelder:**
|
||||||
|
- Anzahl Panels (default: 12)
|
||||||
|
- Watt pro Panel (default: 500, Mond-Wert)
|
||||||
|
- Tag-Länge in Sekunden (default: 600)
|
||||||
|
- Nacht-Länge in Sekunden (default: 600)
|
||||||
|
- Verbrauch in Watt (default: 2000)
|
||||||
|
|
||||||
|
**Formeln:**
|
||||||
|
```
|
||||||
|
Generation = Panels × WattsPerPanel
|
||||||
|
Surplus = Generation - Consumption
|
||||||
|
NightEnergy = Consumption × NightLength (in Watt-Sekunden)
|
||||||
|
BatteriesNeeded = ceil(NightEnergy / 50000) (Station Battery = 50.000 Ws)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ausgabe:**
|
||||||
|
- `Generation: X W`
|
||||||
|
- `Surplus: X W` (rot wenn negativ)
|
||||||
|
- `Night Energy: X Ws`
|
||||||
|
- `Batteries needed: X`
|
||||||
|
|
||||||
|
#### 7d: Atmosphäre / Gas-Mischer
|
||||||
|
|
||||||
|
**Eingabefelder:**
|
||||||
|
- Target Temperatur in Kelvin
|
||||||
|
- Gas 1 Temperatur in Kelvin
|
||||||
|
- Gas 2 Temperatur in Kelvin
|
||||||
|
|
||||||
|
**Formel:**
|
||||||
|
```
|
||||||
|
M1 = |T2 - T0| / (|T1 - T0| + |T2 - T0|)
|
||||||
|
M2 = 1 - M1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ausgabe:**
|
||||||
|
- `Mixer Input 1: X.X%`
|
||||||
|
- `Mixer Input 2: X.X%`
|
||||||
|
|
||||||
|
**Aufklappbare Wärmekapazität-Referenz:**
|
||||||
|
|
||||||
|
| Gas | Cp (J/mol·K) |
|
||||||
|
|---|---|
|
||||||
|
| O₂ | 21.1 |
|
||||||
|
| H₂ | 20.4 |
|
||||||
|
| CO₂ | 28.2 |
|
||||||
|
| N₂ | 20.6 |
|
||||||
|
| H₂O | 72.0 |
|
||||||
|
| N₂O | 23.0 |
|
||||||
|
| Pollutant | 24.8 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 8: UI, i18n und Widget-Sizing
|
||||||
|
|
||||||
|
### Tab-Leiste
|
||||||
|
|
||||||
|
Horizontale Leiste direkt unter dem Widget-Header. Immer sichtbar (kein Scrollen).
|
||||||
|
|
||||||
|
| Tab | Icon | Label |
|
||||||
|
|---|---|---|
|
||||||
|
| Standard | 🔢 | Std |
|
||||||
|
| Scientific | 📐 | Sci |
|
||||||
|
| Converter | ⚖️ | Unit |
|
||||||
|
| Satisfactory | ⚙️ | SAT |
|
||||||
|
| Factorio | 🏭 | FAC |
|
||||||
|
| Stationeers | 🚀 | STA |
|
||||||
|
|
||||||
|
Aktiver Tab: `border-bottom: 2px solid var(--accent)`, Text in `var(--accent)`.
|
||||||
|
Inaktive Tabs: `color: rgba(255,255,255,0.5)`.
|
||||||
|
CSS-Klasse: `.calc-tab-bar` und `.calc-tab`.
|
||||||
|
|
||||||
|
### Widget-Sizing
|
||||||
|
|
||||||
|
- Standard-Modus Minimum: 280 × 400 px
|
||||||
|
- Komplexe Modi (Scientific, Game-Rechner): Auto-Resize auf 320 × 480 px (falls aktuell kleiner)
|
||||||
|
- User-Resize überschreibt Auto-Resize
|
||||||
|
- Widget-System-Minimum bleibt 200 × 150 px
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
Geschätzt ~100 neue Keys in `STRINGS.de` und `STRINGS.en`:
|
||||||
|
|
||||||
|
- 6 Tab-Labels
|
||||||
|
- 6 Kategorie-Namen (Converter)
|
||||||
|
- ~48 Einheiten-Langformen (Converter)
|
||||||
|
- ~30 Feld-Labels (Game-Rechner)
|
||||||
|
- ~10 Ergebnis-Labels
|
||||||
|
|
||||||
|
Einheiten-Abkürzungen (cm, kg, °C, kPa) werden nicht übersetzt.
|
||||||
|
|
||||||
|
### Keyboard
|
||||||
|
|
||||||
|
- Standard-Modus: Bestehender Keyboard-Support (0-9, +, -, *, /, Enter, Backspace, Escape)
|
||||||
|
- Scientific-Modus: Gleicher Support + `p` (Pi), `^` (Potenz)
|
||||||
|
- Converter und Game-Modi: Kein Custom-Keyboard (native `<input>` Felder)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Betroffene Dateien (Gesamt)
|
||||||
|
|
||||||
|
| Datei | Änderung |
|
||||||
|
|---|---|
|
||||||
|
| `src/js/calculator.js` | Tab-System, registerMode(), switchMode(), Parser-Erweiterung (^, sqrt) |
|
||||||
|
| `src/js/calc-scientific.js` | NEU: Scientific-Modus |
|
||||||
|
| `src/js/calc-converter.js` | NEU: Unit-Converter |
|
||||||
|
| `src/js/calc-satisfactory.js` | NEU: Satisfactory Calculator |
|
||||||
|
| `src/js/calc-factorio.js` | NEU: Factorio Calculator |
|
||||||
|
| `src/js/calc-stationeers.js` | NEU: Stationeers Calculator |
|
||||||
|
| `src/css/main.css` | Tab-Bar Styles, Mode-spezifische Styles |
|
||||||
|
| `src/js/i18n.js` | ~100 neue Keys (DE + EN) |
|
||||||
|
| `newtab.html` | 5 neue `<script>` Tags in Load-Order |
|
||||||
|
| `manifest.json` | Version → 2.1.0 |
|
||||||
|
| `manifest.firefox.json` | Version → 2.1.0 |
|
||||||
|
| `manifest.opera.json` | Version → 2.1.0 |
|
||||||
|
| `CHANGELOG.md` | v2.1.0 Eintrag |
|
||||||
|
|
||||||
|
## Implementierungsreihenfolge
|
||||||
|
|
||||||
|
1. **Calculator Core** — Tab-System, registerMode(), switchMode(), Tab-Bar CSS
|
||||||
|
2. **Parser-Erweiterung** — `^` Operator und `sqrt` Funktion
|
||||||
|
3. **Scientific-Modus** — Buttons, Formel-Helfer, Registrierung
|
||||||
|
4. **Unit-Converter** — Kategorien, Einheiten, Konvertierungs-Logik, UI
|
||||||
|
5. **Satisfactory Calculator** — 3 Sub-Modi, Formeln, UI
|
||||||
|
6. **Factorio Calculator** — 3 Sub-Modi, Formeln, UI
|
||||||
|
7. **Stationeers Calculator** — 4 Sub-Modi, Formeln, UI
|
||||||
|
8. **i18n** — Alle neuen Keys (DE + EN)
|
||||||
|
9. **Version Bump** — Manifests, CHANGELOG
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
# Hellion NewTab v2.0.1 — Hardening Release Design
|
||||||
|
|
||||||
|
**Datum:** 2026-04-16
|
||||||
|
**Autor:** Florian Wathling / Claude Code
|
||||||
|
**Status:** Approved
|
||||||
|
**Scope:** Security, Stability, i18n, Code Quality
|
||||||
|
**Strategie:** Foundation First (Event-System zuerst, dann darauf aufbauen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Umfassender Audit von v2.0.0 hat Findings in vier Kategorien ergeben:
|
||||||
|
- 3 Sicherheitslücken (HOCH)
|
||||||
|
- 2 Stabilitätsprobleme (Race Conditions)
|
||||||
|
- 8 fehlende i18n-Attribute
|
||||||
|
- 3 Code-Qualität-Items
|
||||||
|
|
||||||
|
Dieses Design beschreibt alle Fixes als zusammenhängendes Hardening-Release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 1: Widget Event-System
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Calculator (`calculator.js:692-728`), Timer (`timer.js:723-758`) und ImageRef (`image-ref.js:463-498`) überschreiben `WidgetManager.close`, `.minimize` und `.openWidget` durch Monkey-Patching in ihrer `init()`. Das erzeugt eine 3-stufige Closure-Kette pro Methode. Funktional korrekt, aber fragil und schwer debugbar.
|
||||||
|
|
||||||
|
### Lösung
|
||||||
|
|
||||||
|
WidgetManager bekommt ein internes Event-System basierend auf `EventTarget`.
|
||||||
|
|
||||||
|
**Neue API in `widgets.js`:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_emitter: new EventTarget(),
|
||||||
|
|
||||||
|
on(event, handler) {
|
||||||
|
this._emitter.addEventListener(event, handler);
|
||||||
|
},
|
||||||
|
|
||||||
|
off(event, handler) {
|
||||||
|
this._emitter.removeEventListener(event, handler);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**Events:**
|
||||||
|
|
||||||
|
| Event | Feuert nach | Detail |
|
||||||
|
|---|---|---|
|
||||||
|
| `widget:close` | `entry.el.remove()` + `_widgets.delete(id)` | `{ id }` | **Achtung:** Element bereits entfernt, Listener dürfen nicht auf Widget-Entry zugreifen |
|
||||||
|
| `widget:minimize` | State-Änderung + Animation + Save | `{ id }` |
|
||||||
|
| `widget:open` | State-Änderung + Display-Reset + Save | `{ id }` |
|
||||||
|
|
||||||
|
**Migration der Widget-Module:**
|
||||||
|
|
||||||
|
Das gesamte Monkey-Patching wird ersetzt durch `WidgetManager.on()` Aufrufe:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Beispiel: Calculator.init()
|
||||||
|
WidgetManager.on('widget:close', (e) => {
|
||||||
|
if (e.detail.id === self.WIDGET_ID) self.onClose();
|
||||||
|
});
|
||||||
|
WidgetManager.on('widget:minimize', (e) => {
|
||||||
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = false;
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
WidgetManager.on('widget:open', (e) => {
|
||||||
|
if (e.detail.id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = true;
|
||||||
|
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||||
|
if (body && body.children.length === 0) self.renderBody(body);
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
ImageRef folgt dem gleichen Pattern, prüft aber per `self._images.some(img => img.id === id)` statt gegen eine feste WIDGET_ID.
|
||||||
|
|
||||||
|
**Load-Order:** Kein Problem. `widgets.js` wird vor allen Widget-Modulen geladen. Die Module rufen `WidgetManager.on()` in ihrer `init()` auf, die erst in `app.js` aufgerufen wird.
|
||||||
|
|
||||||
|
### Betroffene Dateien
|
||||||
|
|
||||||
|
- `src/js/widgets.js` — Event-System hinzufügen, Events in close/minimize/openWidget dispatchen
|
||||||
|
- `src/js/calculator.js` — Monkey-Patching (Z. 692-728) durch Event-Listener ersetzen
|
||||||
|
- `src/js/timer.js` — Monkey-Patching (Z. 723-758) durch Event-Listener ersetzen
|
||||||
|
- `src/js/image-ref.js` — Monkey-Patching (Z. 463-498) durch Event-Listener ersetzen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 2: Minimize-Animation mit `transitionend`
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
`WidgetManager.minimize()` (`widgets.js:154-163`) setzt `display: none` nach 250ms `setTimeout`. Wenn `openWidget()` in diesen 250ms aufgerufen wird, überschreibt der Timeout das `display: flex` wieder (Race Condition).
|
||||||
|
|
||||||
|
### Lösung
|
||||||
|
|
||||||
|
`setTimeout` wird durch `transitionend` Event ersetzt. Eine `_minimizing` Flag verhindert die Race Condition.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async minimize(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.state.open = false;
|
||||||
|
entry._minimizing = true;
|
||||||
|
entry.el.classList.add('widget-minimized');
|
||||||
|
|
||||||
|
entry.el.addEventListener('transitionend', function onEnd(e) {
|
||||||
|
if (e.target !== entry.el) return;
|
||||||
|
entry.el.removeEventListener('transitionend', onEnd);
|
||||||
|
if (entry._minimizing) {
|
||||||
|
entry.el.style.display = 'none';
|
||||||
|
}
|
||||||
|
entry._minimizing = false;
|
||||||
|
}, { once: false });
|
||||||
|
|
||||||
|
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
async openWidget(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
entry._minimizing = false; // Race Condition verhindert
|
||||||
|
entry.state.open = true;
|
||||||
|
entry.el.style.display = 'flex';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
entry.el.classList.remove('widget-minimized');
|
||||||
|
});
|
||||||
|
this.bringToFront(id);
|
||||||
|
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warum `_minimizing` Flag:** Robuster als `clearTimeout`, weil sie unabhängig von der CSS-Transition-Duration funktioniert.
|
||||||
|
|
||||||
|
**Fallback:** Falls `transitionend` nicht feuert (kein Transition definiert), bleibt das Widget sichtbar mit der Klasse. Akzeptabel, da alle Widgets in `main.css` eine Transition haben.
|
||||||
|
|
||||||
|
### Betroffene Dateien
|
||||||
|
|
||||||
|
- `src/js/widgets.js` — `minimize()` und `openWidget()` umschreiben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 3: Security Fixes
|
||||||
|
|
||||||
|
### 3a: URL-Injection in backgroundImage
|
||||||
|
|
||||||
|
**Datei:** `src/js/settings.js:93`
|
||||||
|
**Problem:** `settings.bgUrl` wird unvalidiert in CSS-Template-Literal eingefügt.
|
||||||
|
|
||||||
|
**Fix:** Protokoll-Whitelist. Nur `blob:` und `data:image/` erlauben (die einzigen Protokolle die der Upload erzeugt).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function isValidBgUrl(url) {
|
||||||
|
return typeof url === 'string' &&
|
||||||
|
(url.startsWith('blob:') || url.startsWith('data:image/'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Validierung an zwei Stellen: `applySettings()` und beim Speichern nach Upload.
|
||||||
|
|
||||||
|
### 3b: URL-Validierung beim JSON-Import
|
||||||
|
|
||||||
|
**Datei:** `src/js/data.js:45-49`
|
||||||
|
**Problem:** Importierte Bookmark-URLs werden nicht auf Protokoll geprüft. `javascript:` oder `data:` URLs kommen durch.
|
||||||
|
|
||||||
|
**Fix:** Protokoll-Whitelist für importierte URLs.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function isSafeUrl(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration in die Bookmark-Filter-Logik: `if (!bm || typeof bm.title !== 'string' || !isSafeUrl(bm.url)) return false;`
|
||||||
|
|
||||||
|
Ungültige Bookmarks werden still übersprungen.
|
||||||
|
|
||||||
|
### 3c: Objekt-Mutation im Import
|
||||||
|
|
||||||
|
**Datei:** `src/js/data.js:43-48`
|
||||||
|
**Problem:** `b.id = b.id || uid()` mutiert das geparste JSON-Objekt direkt. Keine Längenvalidierung.
|
||||||
|
|
||||||
|
**Fix:** Immutable Mapping mit expliziter Feldauswahl und String-Längen-Limits.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
.map(bm => ({
|
||||||
|
id: bm.id || uid(),
|
||||||
|
title: String(bm.title).slice(0, 200),
|
||||||
|
url: bm.url,
|
||||||
|
desc: String(bm.desc || '').slice(0, 500)
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Analog für Boards:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
.map(b => ({
|
||||||
|
id: b.id || uid(),
|
||||||
|
title: String(b.title).slice(0, 100),
|
||||||
|
blurred: !!b.blurred,
|
||||||
|
bookmarks: /* bereits sanitized, siehe oben */
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes-Felder beim Import werden ebenfalls sanitized:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
.filter(n => n && n.id && n.template)
|
||||||
|
.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
|
||||||
|
title: String(n.title || '').slice(0, 200),
|
||||||
|
content: String(n.content || '').slice(0, 5000),
|
||||||
|
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Betroffene Dateien
|
||||||
|
|
||||||
|
- `src/js/settings.js` — `isValidBgUrl()` + Validierung in `applySettings()`
|
||||||
|
- `src/js/data.js` — `isSafeUrl()` + immutable Mapping + Längen-Limits
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 4: Lokale Favicons
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
`getFaviconUrl()` (`state.js:36-43`) ruft Google Favicons API auf. Brave Shields blockiert das. Jeder Bookmark erzeugt einen fehlgeschlagenen Netzwerk-Request. Zusätzlich leakt jeder Hostname an Google.
|
||||||
|
|
||||||
|
### Lösung
|
||||||
|
|
||||||
|
Kein externer Request mehr. `getFaviconUrl()` wird entfernt. Bookmarks zeigen ein farbiges Buchstaben-Icon (erster Buchstabe des Titels).
|
||||||
|
|
||||||
|
**state.js:** `getFaviconUrl()` löschen.
|
||||||
|
|
||||||
|
**boards.js:** Statt `<img>` + Error-Fallback nur noch ein `<div>`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const favicon = document.createElement('div');
|
||||||
|
favicon.className = 'bm-favicon-local';
|
||||||
|
favicon.textContent = bm.title.charAt(0).toUpperCase();
|
||||||
|
// Deterministische Farbe pro Buchstabe
|
||||||
|
const hue = (bm.title.charCodeAt(0) * 137) % 360;
|
||||||
|
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Inline-Style für `backgroundColor` ist hier gerechtfertigt, weil der Wert dynamisch pro Bookmark berechnet wird. Restliche Styles (Größe, Border-Radius, Schrift) kommen aus CSS.
|
||||||
|
|
||||||
|
**main.css:** `.bm-favicon` und `.bm-favicon-fallback` ersetzen durch `.bm-favicon-local`.
|
||||||
|
|
||||||
|
### Was entfällt
|
||||||
|
|
||||||
|
- `getFaviconUrl()` in `state.js`
|
||||||
|
- `<img class="bm-favicon">` Erzeugung in `boards.js`
|
||||||
|
- Error-Listener für Favicon-Loads
|
||||||
|
- `.bm-favicon` und `.bm-favicon-fallback` CSS-Regeln
|
||||||
|
- Der einzige externe Netzwerk-Request der Extension
|
||||||
|
|
||||||
|
### Betroffene Dateien
|
||||||
|
|
||||||
|
- `src/js/state.js` — `getFaviconUrl()` entfernen
|
||||||
|
- `src/js/boards.js` — Favicon-Rendering umbauen
|
||||||
|
- `src/css/main.css` — CSS-Klassen tauschen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 5: i18n-Lücken schließen
|
||||||
|
|
||||||
|
### 5a: Toolbar-Buttons — fehlende `data-i18n-title`
|
||||||
|
|
||||||
|
Fünf Header-Buttons (`newtab.html:26-42`) haben hardcodierte deutsche `title`-Attribute.
|
||||||
|
|
||||||
|
| Button | Key | DE | EN |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `#btnImport` | `header.import_title` | Bookmarks importieren (HTML) | Import bookmarks (HTML) |
|
||||||
|
| `#btnAddBoard` | `header.board_title` | Neues Board hinzufügen | Add new board |
|
||||||
|
| `#btnNote` | `header.note_title` | Schnellnotiz | Quick note |
|
||||||
|
| `#btnTheme` | `header.theme_title` | Darstellung & Theme | Appearance & Theme |
|
||||||
|
| `#btnSettings` | `header.settings_title` | Einstellungen | Settings |
|
||||||
|
|
||||||
|
**Fix:** `data-i18n-title` Attribute hinzufügen. `applyLanguage()` erkennt diese automatisch.
|
||||||
|
|
||||||
|
### 5b: Button-Texte ohne i18n
|
||||||
|
|
||||||
|
Drei Settings-Buttons haben hardcodierte Texte.
|
||||||
|
|
||||||
|
| Button | Key | DE | EN |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `#btnRestartOnboarding` | `settings.onboarding_btn` | Start | Start |
|
||||||
|
| `#btnResetAll` | `settings.reset_btn` | Reset | Reset |
|
||||||
|
| `#btnBgFile` | `settings.bg_upload_btn` | Upload | Upload |
|
||||||
|
|
||||||
|
Aktuell in beiden Sprachen identisch, aber `data-i18n` wird für Konsistenz und zukünftige Erweiterbarkeit gesetzt.
|
||||||
|
|
||||||
|
### Betroffene Dateien
|
||||||
|
|
||||||
|
- `newtab.html` — 5x `data-i18n-title`, 3x `data-i18n` hinzufügen
|
||||||
|
- `src/js/i18n.js` — 8 neue Keys in `STRINGS.de` und `STRINGS.en`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sektion 6: Code-Qualität
|
||||||
|
|
||||||
|
### 6a: Notes-Mutation beim Import
|
||||||
|
|
||||||
|
**Datei:** `src/js/data.js:~79`
|
||||||
|
**Problem:** `Notes._notes = merged` setzt das interne Array direkt, umgeht `Notes.save()`.
|
||||||
|
|
||||||
|
**Fix:** Nach dem Speichern in `widgetStates` wird `Notes.init()` aufgerufen statt das interne Array direkt zu manipulieren.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
existingWidgets.notes = merged;
|
||||||
|
await Store.set('widgetStates', existingWidgets);
|
||||||
|
await Notes.init(); // Neu aus Storage laden + UI rendern
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6b: `backdrop-filter` Fallback
|
||||||
|
|
||||||
|
**Datei:** `src/css/main.css`
|
||||||
|
**Problem:** 24 Stellen mit `backdrop-filter`. Brave Shields kann das blockieren.
|
||||||
|
|
||||||
|
**Fix:** Zentraler `@supports not` Block mit solidem Hintergrund-Fallback:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@supports not (backdrop-filter: blur(1px)) {
|
||||||
|
.board,
|
||||||
|
.widget,
|
||||||
|
.settings-panel,
|
||||||
|
.dialog-box,
|
||||||
|
.theme-modal {
|
||||||
|
background-color: var(--bg-solid-fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Jedes Theme bekommt `--bg-solid-fallback` als deckende Variante der Glassmorphism-Farbe.
|
||||||
|
|
||||||
|
### 6c: Clock Interval Cleanup
|
||||||
|
|
||||||
|
**Datei:** `src/js/app.js:135`
|
||||||
|
**Problem:** `setInterval(tick, 1000)` ID wird nicht gespeichert.
|
||||||
|
|
||||||
|
**Fix:** Interval-ID in Variable speichern. Niedrigste Priorität, da der Interval mit dem Tab stirbt.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
let _clockInterval = null;
|
||||||
|
_clockInterval = setInterval(tick, 1000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Betroffene Dateien
|
||||||
|
|
||||||
|
- `src/js/data.js` — Notes-Import über `Notes.init()` statt direkter Mutation
|
||||||
|
- `src/css/main.css` — `@supports not` Block + `--bg-solid-fallback` pro Theme
|
||||||
|
- `src/js/app.js` — Interval-ID speichern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementierungsreihenfolge (Foundation First)
|
||||||
|
|
||||||
|
1. **Event-System** in `widgets.js` bauen
|
||||||
|
2. **Widget-Module** auf Events migrieren (`calculator.js`, `timer.js`, `image-ref.js`)
|
||||||
|
3. **Minimize mit `transitionend`** in `widgets.js`
|
||||||
|
4. **Security Fixes** in `settings.js` und `data.js`
|
||||||
|
5. **Lokale Favicons** in `state.js`, `boards.js`, `main.css`
|
||||||
|
6. **i18n-Lücken** in `newtab.html` und `i18n.js`
|
||||||
|
7. **Code-Qualität** in `data.js`, `main.css`, `app.js`
|
||||||
|
8. **Version Bump** auf 2.0.1 in allen drei Manifests + CHANGELOG
|
||||||
|
|
||||||
|
## Betroffene Dateien (Gesamt)
|
||||||
|
|
||||||
|
| Datei | Sektionen |
|
||||||
|
|---|---|
|
||||||
|
| `src/js/widgets.js` | 1, 2 |
|
||||||
|
| `src/js/calculator.js` | 1 |
|
||||||
|
| `src/js/timer.js` | 1 |
|
||||||
|
| `src/js/image-ref.js` | 1 |
|
||||||
|
| `src/js/settings.js` | 3a |
|
||||||
|
| `src/js/data.js` | 3b, 3c, 6a |
|
||||||
|
| `src/js/state.js` | 4 |
|
||||||
|
| `src/js/boards.js` | 4 |
|
||||||
|
| `src/js/i18n.js` | 5 |
|
||||||
|
| `src/js/app.js` | 6c |
|
||||||
|
| `src/css/main.css` | 4, 6b |
|
||||||
|
| `newtab.html` | 5 |
|
||||||
|
| `manifest.json` | 8 |
|
||||||
|
| `manifest.firefox.json` | 8 |
|
||||||
|
| `manifest.opera.json` | 8 |
|
||||||
|
| `CHANGELOG.md` | 8 |
|
||||||
+27
-4
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Hellion NewTab",
|
"name": "__MSG_extName__",
|
||||||
"version": "1.11.1",
|
"default_locale": "en",
|
||||||
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
|
"version": "2.3.0",
|
||||||
|
"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",
|
||||||
|
|
||||||
@@ -10,9 +11,28 @@
|
|||||||
"newtab": "newtab.html"
|
"newtab": "newtab.html"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"background": {
|
||||||
|
"scripts": ["src/js/quicksave-core.js", "src/js/background.js"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"action": {
|
||||||
|
"default_title": "Hellion Dashboard"
|
||||||
|
},
|
||||||
|
|
||||||
|
"commands": {
|
||||||
|
"quick-save": {
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Alt+Shift+S",
|
||||||
|
"mac": "Alt+Shift+S"
|
||||||
|
},
|
||||||
|
"description": "__MSG_cmdQuickSave__"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"bookmarks"
|
"bookmarks",
|
||||||
|
"activeTab"
|
||||||
],
|
],
|
||||||
|
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
@@ -33,6 +53,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",
|
||||||
|
|||||||
+24
-4
@@ -1,16 +1,33 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Hellion NewTab",
|
"name": "__MSG_extName__",
|
||||||
"version": "1.11.1",
|
"default_locale": "en",
|
||||||
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
|
"version": "2.3.0",
|
||||||
|
"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",
|
||||||
"chrome_url_overrides": {
|
"chrome_url_overrides": {
|
||||||
"newtab": "newtab.html"
|
"newtab": "newtab.html"
|
||||||
},
|
},
|
||||||
|
"background": {
|
||||||
|
"service_worker": "src/js/background.js"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_title": "Hellion Dashboard"
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"quick-save": {
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Alt+Shift+S",
|
||||||
|
"mac": "Alt+Shift+S"
|
||||||
|
},
|
||||||
|
"description": "__MSG_cmdQuickSave__"
|
||||||
|
}
|
||||||
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"bookmarks"
|
"bookmarks",
|
||||||
|
"activeTab"
|
||||||
],
|
],
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
{
|
{
|
||||||
@@ -18,6 +35,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",
|
||||||
|
|||||||
+17
-3
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Hellion Dashboard (GX Native)",
|
"name": "__MSG_extName__",
|
||||||
"version": "1.11.1",
|
"default_locale": "en",
|
||||||
"description": "Ersetzt die Opera GX Startseite durch dein persönliches, leistungsoptimiertes Hellion Dashboard. Schnell, sauber und werbefrei.",
|
"version": "2.3.0",
|
||||||
|
"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",
|
||||||
|
|
||||||
@@ -39,6 +40,19 @@
|
|||||||
"default_title": "Hellion Dashboard"
|
"default_title": "Hellion Dashboard"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"commands": {
|
||||||
|
"quick-save": {
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Alt+Shift+S",
|
||||||
|
"mac": "Alt+Shift+S"
|
||||||
|
},
|
||||||
|
"description": "__MSG_cmdQuickSave__"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"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",
|
||||||
|
|||||||
+166
-118
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -23,25 +23,29 @@
|
|||||||
</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>
|
||||||
Import
|
<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>
|
||||||
Board
|
<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>
|
||||||
Note
|
<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>
|
||||||
Darstellung
|
<span data-i18n="header.theme">Darstellung</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon" id="btnSettings" title="Einstellungen">
|
<button class="btn-icon" id="btnPalette" title="Lesezeichen durchsuchen (Strg+K)" data-i18n-title="palette.discover">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<span data-i18n="palette.discover_label">Suchen</span>
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
Settings
|
<span data-i18n="header.settings">Settings</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -49,34 +53,34 @@
|
|||||||
<!-- SEARCH BAR -->
|
<!-- SEARCH BAR -->
|
||||||
<div class="search-bar-wrapper" id="searchBarWrapper">
|
<div class="search-bar-wrapper" id="searchBarWrapper">
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<button class="search-engine-toggle" id="searchEngineToggle" title="Suchmaschine wechseln">
|
<button class="search-engine-toggle" id="searchEngineToggle" data-i18n-title="settings.search_engine_toggle" title="Suchmaschine wechseln">
|
||||||
<span id="searchEngineIcon">G</span>
|
<span id="searchEngineIcon">G</span>
|
||||||
</button>
|
</button>
|
||||||
<input type="text" class="search-input" id="searchInput" 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WIDGET TOOLBAR -->
|
<!-- WIDGET TOOLBAR -->
|
||||||
<div class="widget-toolbar" id="widgetToolbar">
|
<div class="widget-toolbar" id="widgetToolbar" role="toolbar" aria-orientation="vertical" data-i18n-aria-label="toolbar.label">
|
||||||
<button class="widget-toolbar-btn" data-action="new-note" title="Note erstellen">
|
<button class="widget-toolbar-btn" data-action="new-note" data-i18n-title="toolbar.note" title="Note erstellen">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="widget-toolbar-btn" data-action="new-checklist" title="Checkliste erstellen">
|
<button class="widget-toolbar-btn" data-action="new-checklist" data-i18n-title="toolbar.checklist" title="Checkliste erstellen">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="widget-toolbar-btn" data-action="calculator" title="Taschenrechner">
|
<button class="widget-toolbar-btn" data-action="calculator" data-i18n-title="toolbar.calculator" title="Taschenrechner">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><circle cx="8" cy="10" r="0.5"/><circle cx="12" cy="10" r="0.5"/><circle cx="16" cy="10" r="0.5"/><circle cx="8" cy="14" r="0.5"/><circle cx="12" cy="14" r="0.5"/><circle cx="16" cy="14" r="0.5"/><circle cx="8" cy="18" r="0.5"/><circle cx="12" cy="18" r="0.5"/><circle cx="16" cy="18" r="0.5"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><circle cx="8" cy="10" r="0.5"/><circle cx="12" cy="10" r="0.5"/><circle cx="16" cy="10" r="0.5"/><circle cx="8" cy="14" r="0.5"/><circle cx="12" cy="14" r="0.5"/><circle cx="16" cy="14" r="0.5"/><circle cx="8" cy="18" r="0.5"/><circle cx="12" cy="18" r="0.5"/><circle cx="16" cy="18" r="0.5"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="widget-toolbar-btn" data-action="timer" title="Timer">
|
<button class="widget-toolbar-btn" data-action="timer" data-i18n-title="toolbar.timer" title="Timer">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3l2 2"/><path d="M19 3l-2 2"/><line x1="12" y1="1" x2="12" y2="3"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3l2 2"/><path d="M19 3l-2 2"/><line x1="12" y1="1" x2="12" y2="3"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="widget-toolbar-btn hidden" data-action="image-ref" title="Bild-Referenz">
|
<button class="widget-toolbar-btn hidden" data-action="image-ref" data-i18n-title="toolbar.imageref" title="Bild-Referenz">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="widget-toolbar-btn" data-action="notebook" title="Alle Notes">
|
<button class="widget-toolbar-btn" data-action="notebook" data-i18n-title="toolbar.notebook" title="Alle Notes">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,8 +89,8 @@
|
|||||||
<div class="notebook-overlay" id="notebookOverlay"></div>
|
<div class="notebook-overlay" id="notebookOverlay"></div>
|
||||||
<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">Notebook <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 -->
|
||||||
@@ -103,37 +107,58 @@
|
|||||||
|
|
||||||
<!-- SETTINGS PANEL -->
|
<!-- SETTINGS PANEL -->
|
||||||
<div class="panel-overlay" id="settingsOverlay"></div>
|
<div class="panel-overlay" id="settingsOverlay"></div>
|
||||||
<aside class="settings-panel" id="settingsPanel">
|
<aside class="settings-panel" id="settingsPanel" role="dialog" aria-modal="true" aria-labelledby="settingsPanelTitle" aria-hidden="true">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span>Einstellungen</span>
|
<span id="settingsPanelTitle" 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">
|
||||||
|
|
||||||
|
<!-- SPRACHE -->
|
||||||
|
<section class="settings-section" data-section="language">
|
||||||
|
<button class="settings-section-title" type="button">
|
||||||
|
<span class="section-chevron">▸</span>
|
||||||
|
<span data-i18n="settings.section.display">DARSTELLUNG</span>
|
||||||
|
</button>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label" data-i18n="settings.language">Sprache</span>
|
||||||
|
<span class="setting-desc" data-i18n="settings.language.desc">Anzeigesprache wählen</span>
|
||||||
|
</div>
|
||||||
|
<select class="select-input" id="settingLanguage">
|
||||||
|
<option value="auto" data-i18n="settings.language.auto">Automatisch</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- WIDGETS -->
|
<!-- WIDGETS -->
|
||||||
<section class="settings-section" data-section="widgets">
|
<section class="settings-section" data-section="widgets">
|
||||||
<button class="settings-section-title" type="button">
|
<button class="settings-section-title" type="button">
|
||||||
<span class="section-chevron">▸</span>
|
<span class="section-chevron">▸</span>
|
||||||
WIDGETS
|
<span data-i18n="settings.section.widgets">WIDGETS</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="section-content">
|
<div class="section-content">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Toolbar-Position</span>
|
<span class="setting-label" data-i18n="settings.toolbar_pos">Toolbar-Position</span>
|
||||||
<span class="setting-desc">Widget-Toolbar links oder rechts anzeigen</span>
|
<span class="setting-desc" data-i18n="settings.toolbar_pos.desc">Widget-Toolbar links oder rechts anzeigen</span>
|
||||||
</div>
|
</div>
|
||||||
<select class="select-input" id="settingToolbarPos">
|
<select class="select-input" id="settingToolbarPos">
|
||||||
<option value="right" selected>Rechts</option>
|
<option value="right" selected data-i18n="settings.toolbar_pos.right">Rechts</option>
|
||||||
<option value="left">Links</option>
|
<option value="left" data-i18n="settings.toolbar_pos.left">Links</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Bild-Referenz Widgets</span>
|
<span class="setting-label" data-i18n="settings.image_ref">Bild-Referenz Widgets</span>
|
||||||
<span class="setting-desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
|
<span class="setting-desc" data-i18n="settings.image_ref.desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" id="settingImageRef">
|
<input type="checkbox" id="settingImageRef" role="switch" aria-checked="false">
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,37 +169,51 @@
|
|||||||
<section class="settings-section" data-section="data">
|
<section class="settings-section" data-section="data">
|
||||||
<button class="settings-section-title" type="button">
|
<button class="settings-section-title" type="button">
|
||||||
<span class="section-chevron">▸</span>
|
<span class="section-chevron">▸</span>
|
||||||
DATEN & HILFE
|
<span data-i18n="settings.section.data">DATEN & HILFE</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="section-content">
|
<div class="section-content">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Backup exportieren</span>
|
<span class="setting-label" data-i18n="settings.export">Backup exportieren</span>
|
||||||
<span class="setting-desc">Alle Boards, Notes und Einstellungen als JSON sichern</span>
|
<span class="setting-desc" data-i18n="settings.export.desc">Alle Boards, Notes und Einstellungen als JSON sichern</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-small" id="btnExportJSON">Export</button>
|
<button class="btn-small" id="btnExportJSON" data-i18n="settings.export.btn">Export</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Backup importieren</span>
|
<span class="setting-label" data-i18n="settings.import">Backup importieren</span>
|
||||||
<span class="setting-desc">JSON-Backup wiederherstellen</span>
|
<span class="setting-desc" data-i18n="settings.import.desc">JSON-Backup wiederherstellen</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-small" id="btnImportJSON">Import</button>
|
<button class="btn-small" id="btnImportJSON" data-i18n="header.import">Import</button>
|
||||||
<input type="file" id="jsonImportInput" accept=".json" class="hidden" />
|
<input type="file" id="jsonImportInput" accept=".json" class="hidden" />
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row" id="browserImportRow">
|
<div class="setting-row" id="browserImportRow">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Browser-Lesezeichen</span>
|
<span class="setting-label" data-i18n="settings.browser_import">Browser-Lesezeichen</span>
|
||||||
<span class="setting-desc">Lesezeichen direkt aus dem Browser importieren</span>
|
<span class="setting-desc" data-i18n="settings.browser_import.desc">Lesezeichen direkt aus dem Browser importieren</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-small" id="btnBrowserImport">Import</button>
|
<button class="btn-small" id="btnBrowserImport" data-i18n="header.import">Import</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Onboarding wiederholen</span>
|
<span class="setting-label" data-i18n="settings.onboarding">Onboarding wiederholen</span>
|
||||||
<span class="setting-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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PAPIERKORB / TRASH -->
|
||||||
|
<section class="settings-section" data-section="trash">
|
||||||
|
<button class="settings-section-title" type="button">
|
||||||
|
<span class="section-chevron">▸</span>
|
||||||
|
<span data-i18n="trash.section">PAPIERKORB</span>
|
||||||
|
</button>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="trash-list" id="trashList"></div>
|
||||||
|
<div class="setting-row trash-actions" id="trashActionsRow">
|
||||||
|
<button class="btn-danger" id="btnEmptyTrash" data-i18n="trash.empty_btn">Papierkorb leeren</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -183,15 +222,15 @@
|
|||||||
<section class="settings-section" data-section="danger">
|
<section class="settings-section" data-section="danger">
|
||||||
<button class="settings-section-title danger" type="button">
|
<button class="settings-section-title danger" type="button">
|
||||||
<span class="section-chevron">▸</span>
|
<span class="section-chevron">▸</span>
|
||||||
DANGER ZONE
|
<span data-i18n="settings.section.danger">DANGER ZONE</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="section-content">
|
<div class="section-content">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Alles zurücksetzen</span>
|
<span class="setting-label" data-i18n="settings.reset">Alles zurücksetzen</span>
|
||||||
<span class="setting-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>
|
||||||
@@ -201,13 +240,13 @@
|
|||||||
<!-- ABOUT — fixiert am unteren Rand -->
|
<!-- ABOUT — fixiert am unteren Rand -->
|
||||||
<div class="panel-footer">
|
<div class="panel-footer">
|
||||||
<div class="about-block">
|
<div class="about-block">
|
||||||
<div class="about-logo">⬡ HELLION NEWTAB</div>
|
<div class="about-logo" data-i18n="about.title">⬡ HELLION NEWTAB</div>
|
||||||
<div class="about-version">Version 1.11.1 · by Hellion Online Media</div>
|
<div class="about-version">Version 2.3.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">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
Impressum
|
<span data-i18n="about.impressum">Impressum</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://hellion-media.de" target="_blank" class="about-link">
|
<a href="https://hellion-media.de" target="_blank" class="about-link">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||||
@@ -218,26 +257,26 @@
|
|||||||
<div class="about-divider"></div>
|
<div class="about-divider"></div>
|
||||||
|
|
||||||
<div class="about-info-row">
|
<div class="about-info-row">
|
||||||
<span class="about-info-label">Entwickler</span>
|
<span class="about-info-label" data-i18n="about.developer">Entwickler</span>
|
||||||
<span class="about-info-value">Florian Wathling</span>
|
<span class="about-info-value">Florian Wathling</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-info-row">
|
<div class="about-info-row">
|
||||||
<span class="about-info-label">Unternehmen</span>
|
<span class="about-info-label" data-i18n="about.company">Unternehmen</span>
|
||||||
<span class="about-info-value">Hellion Online Media</span>
|
<span class="about-info-value">Hellion Online Media</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-info-row">
|
<div class="about-info-row">
|
||||||
<span class="about-info-label">Lizenz</span>
|
<span class="about-info-label" data-i18n="about.license">Lizenz</span>
|
||||||
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" class="about-info-value about-link-subtle">CC BY-NC-SA 4.0</a>
|
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" class="about-info-value about-link-subtle">CC BY-NC-SA 4.0</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-info-row">
|
<div class="about-info-row">
|
||||||
<span class="about-info-label">Datenspeicherung</span>
|
<span class="about-info-label" data-i18n="about.storage">Datenspeicherung</span>
|
||||||
<span class="about-info-value">100% lokal · Kein Server · Kein Account</span>
|
<span class="about-info-value" data-i18n="about.storage.value">100% lokal · Kein Server · Kein Account</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="about-divider"></div>
|
<div class="about-divider"></div>
|
||||||
|
|
||||||
<div class="about-bugreport">
|
<div class="about-bugreport">
|
||||||
<span class="about-info-label about-info-label-block">Bug Report / Feedback</span>
|
<span class="about-info-label about-info-label-block" data-i18n="about.bugreport">Bug Report / Feedback</span>
|
||||||
<a href="mailto:kontakt@hellion-media.de?subject=Hellion NewTab – Bug Report" class="about-link-mail">
|
<a href="mailto:kontakt@hellion-media.de?subject=Hellion NewTab – Bug Report" class="about-link-mail">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||||
kontakt@hellion-media.de
|
kontakt@hellion-media.de
|
||||||
@@ -245,7 +284,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="about-bugreport">
|
<div class="about-bugreport">
|
||||||
<span class="about-info-label about-info-label-block">Support</span>
|
<span class="about-info-label about-info-label-block" data-i18n="about.support">Support</span>
|
||||||
<a href="https://ko-fi.com/hellionmedia" target="_blank" class="about-link">
|
<a href="https://ko-fi.com/hellionmedia" target="_blank" class="about-link">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 010 8h-1"/><path d="M2 8h16v9a4 4 0 01-4 4H6a4 4 0 01-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 010 8h-1"/><path d="M2 8h16v9a4 4 0 01-4 4H6a4 4 0 01-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>
|
||||||
Ko-fi — hellionmedia
|
Ko-fi — hellionmedia
|
||||||
@@ -253,7 +292,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="about-browsers">
|
<div class="about-browsers">
|
||||||
<span class="about-info-label about-info-label-block">Kompatible Browser</span>
|
<span class="about-info-label about-info-label-block" data-i18n="about.browsers">Kompatible Browser</span>
|
||||||
<div class="about-browser-tags">
|
<div class="about-browser-tags">
|
||||||
<span class="browser-tag">Chrome</span>
|
<span class="browser-tag">Chrome</span>
|
||||||
<span class="browser-tag">Edge</span>
|
<span class="browser-tag">Edge</span>
|
||||||
@@ -270,138 +309,138 @@
|
|||||||
|
|
||||||
<!-- THEME PICKER MODAL -->
|
<!-- THEME PICKER MODAL -->
|
||||||
<div class="modal-overlay" id="themeOverlay">
|
<div class="modal-overlay" id="themeOverlay">
|
||||||
<div class="theme-modal" id="themeModal">
|
<div class="theme-modal" id="themeModal" role="dialog" aria-modal="true" aria-labelledby="themeModalTitle" aria-hidden="true">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span>Darstellung</span>
|
<span id="themeModalTitle" 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" role="button" tabindex="0" aria-pressed="true" data-i18n-aria-label="theme.card.nebula">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-nebula.webp" alt="Nebula" />
|
<img class="theme-card-img" src="assets/themes/bg-nebula.webp" alt="Nebula" />
|
||||||
<span class="theme-card-label">Nebula</span>
|
<span class="theme-card-label">Nebula</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="crescent">
|
<div class="theme-card" data-value="crescent" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.crescent">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-crescent.webp" alt="Crescent" />
|
<img class="theme-card-img" src="assets/themes/bg-crescent.webp" alt="Crescent" />
|
||||||
<span class="theme-card-label">Crescent</span>
|
<span class="theme-card-label">Crescent</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="event-horizon">
|
<div class="theme-card" data-value="event-horizon" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.event_horizon">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-event-horizon.webp" alt="Event Horizon" />
|
<img class="theme-card-img" src="assets/themes/bg-event-horizon.webp" alt="Event Horizon" />
|
||||||
<span class="theme-card-label">Event Horizon</span>
|
<span class="theme-card-label">Event Horizon</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="merchantman">
|
<div class="theme-card" data-value="merchantman" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.merchantman">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-merchantman.webp" alt="Merchantman" />
|
<img class="theme-card-img" src="assets/themes/bg-merchantman.webp" alt="Merchantman" />
|
||||||
<span class="theme-card-label">Merchantman</span>
|
<span class="theme-card-label">Merchantman</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="julia-jin">
|
<div class="theme-card" data-value="julia-jin" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.julia_jin">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-julia-jin.webp" alt="Julia & Jin" />
|
<img class="theme-card-img" src="assets/themes/bg-julia-jin.webp" alt="Julia & Jin" />
|
||||||
<span class="theme-card-label">Julia & Jin</span>
|
<span class="theme-card-label">Julia & Jin</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="sc-sunset">
|
<div class="theme-card" data-value="sc-sunset" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.sc_sunset">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-sc-sunset.webp" alt="SC Sunset" />
|
<img class="theme-card-img" src="assets/themes/bg-sc-sunset.webp" alt="SC Sunset" />
|
||||||
<span class="theme-card-label">SC Sunset</span>
|
<span class="theme-card-label">SC Sunset</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="hellion-hud">
|
<div class="theme-card" data-value="hellion-hud" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.hellion_hud">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-hellion-hud.webp" alt="Hellion HUD" />
|
<img class="theme-card-img" src="assets/themes/bg-hellion-hud.webp" alt="Hellion HUD" />
|
||||||
<span class="theme-card-label">HUD</span>
|
<span class="theme-card-label">HUD</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="hellion-energy">
|
<div class="theme-card" data-value="hellion-energy" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.hellion_energy">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-hellion-energy.webp" alt="Hellion Energy" />
|
<img class="theme-card-img" src="assets/themes/bg-hellion-energy.webp" alt="Hellion Energy" />
|
||||||
<span class="theme-card-label">Energy</span>
|
<span class="theme-card-label">Energy</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="satisfactory">
|
<div class="theme-card" data-value="satisfactory" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.satisfactory">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-satisfactory.webp" alt="Satisfactory" />
|
<img class="theme-card-img" src="assets/themes/bg-satisfactory.webp" alt="Satisfactory" />
|
||||||
<span class="theme-card-label">Satisfactory</span>
|
<span class="theme-card-label">Satisfactory</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="avorion">
|
<div class="theme-card" data-value="avorion" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.avorion">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-avorion.webp" alt="Avorion" />
|
<img class="theme-card-img" src="assets/themes/bg-avorion.webp" alt="Avorion" />
|
||||||
<span class="theme-card-label">Avorion</span>
|
<span class="theme-card-label">Avorion</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="hellion-stealth">
|
<div class="theme-card" data-value="hellion-stealth" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.hellion_stealth">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-scPolaris.webp" alt="Hellion Stealth" />
|
<img class="theme-card-img" src="assets/themes/bg-scPolaris.webp" alt="Hellion Stealth" />
|
||||||
<span class="theme-card-label">Stealth</span>
|
<span class="theme-card-label">Stealth</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-modal-section">
|
<div class="theme-modal-section">
|
||||||
<h3 class="settings-section-title">HINTERGRUND</h3>
|
<h3 class="settings-section-title" data-i18n="settings.section.bg">HINTERGRUND</h3>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Bild-URL</span>
|
<span class="setting-label" data-i18n="settings.bg_url">Bild-URL</span>
|
||||||
<span class="setting-desc">Eigenes Hintergrundbild per URL</span>
|
<span class="setting-desc" data-i18n="settings.bg_url.desc">Eigenes Hintergrundbild per URL</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-small" id="btnChangeBg">Ändern</button>
|
<button class="btn-small" id="btnChangeBg" data-i18n="settings.bg_change">Ändern</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row hidden" id="bgInputRow">
|
<div class="setting-row hidden" id="bgInputRow">
|
||||||
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... oder leer für Standard" />
|
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... oder leer für Standard" />
|
||||||
<button class="btn-small" id="btnApplyBg">Übernehmen</button>
|
<button class="btn-small" id="btnApplyBg" data-i18n="settings.bg_apply">Übernehmen</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Datei hochladen</span>
|
<span class="setting-label" data-i18n="settings.bg_upload">Datei hochladen</span>
|
||||||
<span class="setting-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>
|
||||||
<div class="theme-modal-section">
|
<div class="theme-modal-section">
|
||||||
<h3 class="settings-section-title">DARSTELLUNG</h3>
|
<h3 class="settings-section-title" data-i18n="settings.section.display">DARSTELLUNG</h3>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Kompaktmodus</span>
|
<span class="setting-label" data-i18n="settings.compact">Kompaktmodus</span>
|
||||||
<span class="setting-desc">Weniger Abstand für mehr Bookmarks</span>
|
<span class="setting-desc" data-i18n="settings.compact.desc">Weniger Abstand für mehr Bookmarks</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle"><input type="checkbox" id="settingCompact" /><span class="slider"></span></label>
|
<label class="toggle"><input type="checkbox" id="settingCompact" role="switch" aria-checked="false" /><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Lange Titel kürzen</span>
|
<span class="setting-label" data-i18n="settings.shorten">Lange Titel kürzen</span>
|
||||||
<span class="setting-desc">Titel auf eine Zeile mit „…" kürzen</span>
|
<span class="setting-desc" data-i18n="settings.shorten.desc">Titel auf eine Zeile mit „…" kürzen</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle"><input type="checkbox" id="settingShorten" /><span class="slider"></span></label>
|
<label class="toggle"><input type="checkbox" id="settingShorten" role="switch" aria-checked="false" /><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Suchleiste anzeigen</span>
|
<span class="setting-label" data-i18n="settings.search">Suchleiste anzeigen</span>
|
||||||
<span class="setting-desc">Suchleiste unter dem Header ein/aus</span>
|
<span class="setting-desc" data-i18n="settings.search.desc">Suchleiste unter dem Header ein/aus</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle"><input type="checkbox" id="settingShowSearch" checked /><span class="slider"></span></label>
|
<label class="toggle"><input type="checkbox" id="settingShowSearch" role="switch" aria-checked="true" checked /><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Links in neuem Tab</span>
|
<span class="setting-label" data-i18n="settings.newtab">Links in neuem Tab</span>
|
||||||
<span class="setting-desc">Bookmarks in neuem Browser-Tab öffnen</span>
|
<span class="setting-desc" data-i18n="settings.newtab.desc">Bookmarks in neuem Browser-Tab öffnen</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle"><input type="checkbox" id="settingNewTab" checked /><span class="slider"></span></label>
|
<label class="toggle"><input type="checkbox" id="settingNewTab" role="switch" aria-checked="true" checked /><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Beschreibungen anzeigen</span>
|
<span class="setting-label" data-i18n="settings.showdesc">Beschreibungen anzeigen</span>
|
||||||
<span class="setting-desc">Gespeicherte Beschreibung unter Bookmarks</span>
|
<span class="setting-desc" data-i18n="settings.showdesc.desc">Gespeicherte Beschreibung unter Bookmarks</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle"><input type="checkbox" id="settingShowDesc" /><span class="slider"></span></label>
|
<label class="toggle"><input type="checkbox" id="settingShowDesc" role="switch" aria-checked="false" /><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Bookmarks ausblenden</span>
|
<span class="setting-label" data-i18n="settings.hideextra">Bookmarks ausblenden</span>
|
||||||
<span class="setting-desc">Überzählige Bookmarks in langen Boards verstecken</span>
|
<span class="setting-desc" data-i18n="settings.hideextra.desc">Überzählige Bookmarks in langen Boards verstecken</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle"><input type="checkbox" id="settingHideExtra" /><span class="slider"></span></label>
|
<label class="toggle"><input type="checkbox" id="settingHideExtra" role="switch" aria-checked="false" /><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row" id="visibleCountRow">
|
<div class="setting-row" id="visibleCountRow">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Sichtbare Bookmarks</span>
|
<span class="setting-label" data-i18n="settings.visible_count">Sichtbare Bookmarks</span>
|
||||||
<span class="setting-desc">Anzahl vor dem Ausblenden</span>
|
<span class="setting-desc" data-i18n="settings.visible_count.desc">Anzahl vor dem Ausblenden</span>
|
||||||
</div>
|
</div>
|
||||||
<select class="select-input" id="settingVisibleCount">
|
<select class="select-input" id="settingVisibleCount">
|
||||||
<option value="5">5</option>
|
<option value="5">5</option>
|
||||||
@@ -417,14 +456,14 @@
|
|||||||
<div class="modal-overlay" id="addBoardOverlay">
|
<div class="modal-overlay" id="addBoardOverlay">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span>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" placeholder="Board name..." maxlength="40" />
|
<input type="text" class="text-input full-width" id="newBoardName" data-i18n-placeholder="modal.board_name" placeholder="Board name..." maxlength="40" />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn-primary" id="btnConfirmBoard">Create</button>
|
<button class="btn-primary" id="btnConfirmBoard" data-i18n="modal.create">Create</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,16 +472,16 @@
|
|||||||
<div class="modal-overlay" id="addBookmarkOverlay">
|
<div class="modal-overlay" id="addBookmarkOverlay">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span>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" placeholder="Title..." maxlength="60" />
|
<input type="text" class="text-input full-width" id="newBmTitle" data-i18n-placeholder="modal.bm_title" placeholder="Title..." maxlength="60" />
|
||||||
<input type="url" class="text-input full-width modal-input-spaced" id="newBmUrl" placeholder="https://..." />
|
<input type="url" class="text-input full-width modal-input-spaced" id="newBmUrl" placeholder="https://..." />
|
||||||
<input type="text" class="text-input full-width modal-input-spaced" id="newBmDesc" placeholder="Description (optional)" />
|
<input type="text" class="text-input full-width modal-input-spaced" id="newBmDesc" data-i18n-placeholder="modal.bm_desc" placeholder="Description (optional)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn-primary" id="btnConfirmBookmark">Add</button>
|
<button class="btn-primary" id="btnConfirmBookmark" data-i18n="modal.bm_add">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -451,14 +490,14 @@
|
|||||||
<div class="modal-overlay" id="renameOverlay">
|
<div class="modal-overlay" id="renameOverlay">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span>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" placeholder="New name..." maxlength="60" />
|
<input type="text" class="text-input full-width" id="renameInput" data-i18n-placeholder="modal.rename_placeholder" placeholder="New name..." maxlength="60" />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn-primary" id="btnConfirmRename">Rename</button>
|
<button class="btn-primary" id="btnConfirmRename" data-i18n="modal.rename_confirm">Rename</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,7 +510,10 @@
|
|||||||
<!-- Storage muss zuerst -->
|
<!-- Storage muss zuerst -->
|
||||||
<script src="src/js/storage.js"></script>
|
<script src="src/js/storage.js"></script>
|
||||||
<!-- State & Hilfsfunktionen -->
|
<!-- State & Hilfsfunktionen -->
|
||||||
|
<script src="src/js/quicksave-core.js"></script>
|
||||||
<script src="src/js/state.js"></script>
|
<script src="src/js/state.js"></script>
|
||||||
|
<!-- i18n (nach state.js, vor allen Modulen die t() nutzen könnten) -->
|
||||||
|
<script src="src/js/i18n.js"></script>
|
||||||
<!-- Dialog-System (vor Features, wird überall gebraucht) -->
|
<!-- Dialog-System (vor Features, wird überall gebraucht) -->
|
||||||
<script src="src/js/dialog.js"></script>
|
<script src="src/js/dialog.js"></script>
|
||||||
<!-- Theme-System -->
|
<!-- Theme-System -->
|
||||||
@@ -481,9 +523,15 @@
|
|||||||
<script src="src/js/boards.js"></script>
|
<script src="src/js/boards.js"></script>
|
||||||
<script src="src/js/settings.js"></script>
|
<script src="src/js/settings.js"></script>
|
||||||
<script src="src/js/search.js"></script>
|
<script src="src/js/search.js"></script>
|
||||||
|
<script src="src/js/palette.js"></script>
|
||||||
<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,55 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
":dependencyDashboard",
|
||||||
|
":semanticCommits",
|
||||||
|
":timezone(Europe/Berlin)",
|
||||||
|
"schedule:weekly"
|
||||||
|
],
|
||||||
|
"labels": ["dependencies", "renovate"],
|
||||||
|
"assignees": ["JonKazama-Hellion"],
|
||||||
|
"prHourlyLimit": 10,
|
||||||
|
"prConcurrentLimit": 20,
|
||||||
|
"rebaseWhen": "behind-base-branch",
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Group all minor and patch updates per ecosystem in one PR",
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "minor and patch updates ({{manager}})"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Major updates always get their own PR with breaking-change label",
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"labels": ["dependencies", "major-update", "breaking-change"],
|
||||||
|
"addLabels": ["needs-review"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "TypeScript type definitions stay grouped with each other",
|
||||||
|
"groupName": "type definitions",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"@types/{/,}**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Dev dependencies in their own group",
|
||||||
|
"matchDepTypes": ["devDependencies"],
|
||||||
|
"groupName": "dev dependencies"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Pin GitHub Action versions by SHA for supply-chain hygiene",
|
||||||
|
"matchManagers": ["github-actions"],
|
||||||
|
"pinDigests": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vulnerabilityAlerts": {
|
||||||
|
"labels": ["security", "vulnerability"],
|
||||||
|
"schedule": ["at any time"]
|
||||||
|
},
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": ["before 6am on monday"],
|
||||||
|
"commitMessageAction": "Refresh"
|
||||||
|
},
|
||||||
|
"osvVulnerabilityAlerts": true
|
||||||
|
}
|
||||||
+643
-142
File diff suppressed because it is too large
Load Diff
+117
-11
@@ -6,16 +6,31 @@
|
|||||||
async function init() {
|
async function init() {
|
||||||
const savedBoards = await Store.get('boards');
|
const savedBoards = await Store.get('boards');
|
||||||
const savedSettings = await Store.get('settings');
|
const savedSettings = await Store.get('settings');
|
||||||
|
const savedTrash = await Store.get('trash');
|
||||||
|
|
||||||
boards = savedBoards ?? getDefaultBoards();
|
boards = savedBoards ?? getDefaultBoards();
|
||||||
|
trash = Array.isArray(savedTrash) ? savedTrash : [];
|
||||||
|
|
||||||
|
// Auto-Cleanup: Eintraege aelter als 30 Tage verwerfen (TRASH-02). Muss VOR
|
||||||
|
// renderBoards() laufen, damit der Papierkorb-Stand konsistent ist. Schreibt nur
|
||||||
|
// zurueck, wenn wirklich etwas entfernt wurde (kein unnoetiger Storage-Write).
|
||||||
|
const cutoff = Date.now() - TRASH_RETENTION_MS;
|
||||||
|
const beforeCount = trash.length;
|
||||||
|
trash = trash.filter(entry => entry && typeof entry.deletedAt === 'number' && Number.isFinite(entry.deletedAt) && entry.deletedAt >= cutoff);
|
||||||
|
if (trash.length !== beforeCount) await saveTrash();
|
||||||
if (savedSettings) Object.assign(settings, savedSettings);
|
if (savedSettings) Object.assign(settings, savedSettings);
|
||||||
|
|
||||||
|
I18n.init();
|
||||||
applySettings();
|
applySettings();
|
||||||
renderBoards();
|
renderBoards();
|
||||||
startClock();
|
startClock();
|
||||||
bindGlobalEvents();
|
bindGlobalEvents();
|
||||||
bindSettingsEvents();
|
bindSettingsEvents();
|
||||||
|
bindStorageSync();
|
||||||
|
bindBoardResizeReclamp(); // Boards bei Fenster-Verkleinerung wieder in den sichtbaren Bereich holen
|
||||||
|
await drainQuickSavePending(); // beim Start angesammelte Quick-Saves (kein Tab war offen) einlesen
|
||||||
initSearch();
|
initSearch();
|
||||||
|
initPalette();
|
||||||
await migrateSticky();
|
await migrateSticky();
|
||||||
await Notes.init();
|
await Notes.init();
|
||||||
await Calculator.init();
|
await Calculator.init();
|
||||||
@@ -94,8 +109,8 @@ async function checkBackupReminder() {
|
|||||||
if (boards.length === 0) return;
|
if (boards.length === 0) return;
|
||||||
|
|
||||||
const doBackup = await HellionDialog.confirm(
|
const doBackup = await HellionDialog.confirm(
|
||||||
'Du hast seit über einer Woche kein Backup gemacht. Beim Löschen der Browserdaten gehen deine Boards verloren. Jetzt sichern?',
|
t('app.backup_reminder'),
|
||||||
{ type: 'warning', title: 'Backup-Erinnerung', confirmText: 'Jetzt sichern', cancelText: 'Später' }
|
{ type: 'warning', title: t('app.backup_reminder.title'), confirmText: t('app.backup_now'), cancelText: t('app.backup_later') }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (doBackup) {
|
if (doBackup) {
|
||||||
@@ -104,7 +119,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: '1.11.1', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets };
|
const data = { version: '2.3.0', exported: new Date().toISOString(), boards, settings, trash, 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');
|
||||||
@@ -120,18 +135,18 @@ async function checkBackupReminder() {
|
|||||||
|
|
||||||
// ---- CLOCK & DATE ----
|
// ---- CLOCK & DATE ----
|
||||||
function startClock() {
|
function startClock() {
|
||||||
const DAYS = ['So','Mo','Di','Mi','Do','Fr','Sa'];
|
const DAY_KEYS = ['clock.days.sun','clock.days.mon','clock.days.tue','clock.days.wed','clock.days.thu','clock.days.fri','clock.days.sat'];
|
||||||
const MONTHS = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
|
const MONTH_KEYS = ['clock.months.jan','clock.months.feb','clock.months.mar','clock.months.apr','clock.months.may','clock.months.jun','clock.months.jul','clock.months.aug','clock.months.sep','clock.months.oct','clock.months.nov','clock.months.dec'];
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
document.getElementById('clock').textContent =
|
document.getElementById('clock').textContent =
|
||||||
`${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
`${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||||||
document.getElementById('date').textContent =
|
document.getElementById('date').textContent =
|
||||||
`${DAYS[now.getDay()]}, ${String(now.getDate()).padStart(2,'0')}. ${MONTHS[now.getMonth()]}`;
|
`${t(DAY_KEYS[now.getDay()])}, ${String(now.getDate()).padStart(2,'0')}. ${t(MONTH_KEYS[now.getMonth()])}`;
|
||||||
}
|
}
|
||||||
tick();
|
tick();
|
||||||
setInterval(tick, 1000);
|
const clockInterval = setInterval(tick, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- GLOBALE EVENTS (Header-Buttons, Modals, Import) ----
|
// ---- GLOBALE EVENTS (Header-Buttons, Modals, Import) ----
|
||||||
@@ -148,7 +163,7 @@ function bindGlobalEvents() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
const imported = parseBookmarkHtml(await file.text());
|
const imported = parseBookmarkHtml(await file.text());
|
||||||
if (imported.length === 0) {
|
if (imported.length === 0) {
|
||||||
await HellionDialog.alert('Keine Bookmarks in dieser Datei gefunden.', { type: 'warning', title: 'Import' });
|
await HellionDialog.alert(t('app.no_bookmarks'), { type: 'warning', title: t('app.import_title') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boards = [...boards, ...imported];
|
boards = [...boards, ...imported];
|
||||||
@@ -156,8 +171,8 @@ function bindGlobalEvents() {
|
|||||||
renderBoards();
|
renderBoards();
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
`${imported.length} Board(s) mit ${imported.reduce((s,b) => s + b.bookmarks.length, 0)} Bookmarks importiert.`,
|
t('app.html_import_success', { count: imported.length, total: imported.reduce((s,b) => s + b.bookmarks.length, 0) }),
|
||||||
{ type: 'success', title: 'Import erfolgreich' }
|
{ type: 'success', title: t('app.import_success_title') }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,7 +204,7 @@ function bindGlobalEvents() {
|
|||||||
const url = document.getElementById('newBmUrl').value.trim();
|
const url = document.getElementById('newBmUrl').value.trim();
|
||||||
const desc = document.getElementById('newBmDesc').value.trim();
|
const desc = document.getElementById('newBmDesc').value.trim();
|
||||||
if (!title || !url) return;
|
if (!title || !url) return;
|
||||||
try { new URL(url); } catch { await HellionDialog.alert('Ungültige URL. Bitte mit https:// beginnen.', { type: 'warning', title: 'URL ungültig' }); return; }
|
try { new URL(url); } catch { await HellionDialog.alert(t('app.invalid_url'), { type: 'warning', title: t('app.invalid_url.title') }); return; }
|
||||||
const board = boards.find(b => b.id === pendingBookmarkBoardId);
|
const board = boards.find(b => b.id === pendingBookmarkBoardId);
|
||||||
if (!board) return;
|
if (!board) return;
|
||||||
board.bookmarks.push({ id: uid(), title, url, desc });
|
board.bookmarks.push({ id: uid(), title, url, desc });
|
||||||
@@ -219,4 +234,95 @@ function bindGlobalEvents() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- QUICK-SAVE PENDING-QUEUE ----
|
||||||
|
// Der Background-Worker haengt Quick-Saves an den eigenen Store-Key 'quicksave_pending' an (er
|
||||||
|
// schreibt NIE boards). Diese Seite ist die einzige boards-Schreiberin und drained die Queue in die
|
||||||
|
// Inbox. Getrennte Schreib-Domaenen -> Worker und Seite koennen sich nicht im boards-Array
|
||||||
|
// gegenseitig ueberschreiben (Datensicherheit, Phase-4-Review-Blocker 2b).
|
||||||
|
let _drainBusy = false;
|
||||||
|
let _drainQueued = false; // ein waehrend eines laufenden Drains angefragter Drain wird nachgeholt
|
||||||
|
let _renderDeferredByDrag = false; // Drain hat den Render wegen eines laufenden Drags ausgelassen -> nach Drag-Ende nachholen
|
||||||
|
async function drainQuickSavePending() {
|
||||||
|
if (_drainBusy) { _drainQueued = true; return; }
|
||||||
|
_drainBusy = true;
|
||||||
|
try {
|
||||||
|
const pending = await Store.get('quicksave_pending');
|
||||||
|
if (Array.isArray(pending) && pending.length > 0) {
|
||||||
|
const drained = pending.slice();
|
||||||
|
const drainedIds = new Set(drained.map(e => e && e.id).filter(Boolean));
|
||||||
|
const inbox = await ensureInboxBoard(); // legt die Inbox an, falls noetig; gibt das Board zurueck
|
||||||
|
// Idempotenz gegen den Worker/Drain-Race auf 'quicksave_pending': jede eingespielte Inbox-
|
||||||
|
// Bookmark traegt die Pending-id ihres Ursprungs als srcId. Taucht ein bereits gedrainter
|
||||||
|
// Eintrag durch einen gleichzeitigen Worker-Append erneut in der Queue auf, wird er hier
|
||||||
|
// uebersprungen statt doppelt eingefuegt — kein Duplikat, und kein Verlust (boards-Write
|
||||||
|
// bleibt vor der Queue-Bereinigung, daher keine umgekehrte Verlustgefahr).
|
||||||
|
const seenSrc = new Set(inbox.bookmarks.map(b => b && b.srcId).filter(Boolean));
|
||||||
|
for (const e of drained) {
|
||||||
|
if (!e || !e.id || seenSrc.has(e.id)) continue; // schon eingespielt
|
||||||
|
if (typeof e.url !== 'string' || !e.url || !isSafeUrl(e.url)) continue; // leeres/unsicheres Protokoll verwerfen
|
||||||
|
const bm = normalizeBookmark({ title: e.title, url: e.url });
|
||||||
|
bm.srcId = e.id; // Herkunft fuer kuenftige Dedup
|
||||||
|
inbox.bookmarks.push(bm);
|
||||||
|
seenSrc.add(e.id);
|
||||||
|
}
|
||||||
|
await saveBoards();
|
||||||
|
// NUR die verarbeiteten Eintraege entfernen — ein gleichzeitiger Worker-Append bleibt erhalten.
|
||||||
|
const still = await Store.get('quicksave_pending');
|
||||||
|
const remaining = Array.isArray(still) ? still.filter(e => e && !drainedIds.has(e.id)) : [];
|
||||||
|
await Store.set('quicksave_pending', remaining);
|
||||||
|
// Render nur, wenn gerade KEIN Drag laeuft (renderBoards->replaceChildren wuerde ihn abreissen).
|
||||||
|
// Laeuft einer, den Render-Wunsch merken und nach Drag-Ende nachholen (drag.js ruft
|
||||||
|
// flushQuickSaveRenderIfDeferred), sonst bliebe der frisch gedrainte Quick-Save bis zu einem
|
||||||
|
// unabhaengigen Fremd-Render unsichtbar (Phase-6-Review).
|
||||||
|
if (document.querySelector('.board.dragging, .bm-item.dragging-source')) {
|
||||||
|
_renderDeferredByDrag = true;
|
||||||
|
} else {
|
||||||
|
renderBoards();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Quick-Save-Drain fehlgeschlagen:', e && e.message);
|
||||||
|
} finally {
|
||||||
|
_drainBusy = false;
|
||||||
|
}
|
||||||
|
// Kam waehrend des Drains ein weiterer Quick-Save an (onChanged wurde durch _drainBusy verworfen),
|
||||||
|
// jetzt nachholen. Der Eintrag war sicher in der Queue, nur noch nicht eingelesen.
|
||||||
|
if (_drainQueued) { _drainQueued = false; drainQuickSavePending(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wird von drag.js nach jedem Drag-Ende aufgerufen: einen waehrend des Drags ausgelassenen
|
||||||
|
// Quick-Save-Render nachholen. Idempotent — tut nichts, wenn kein Render aussteht.
|
||||||
|
function flushQuickSaveRenderIfDeferred() {
|
||||||
|
if (_renderDeferredByDrag) {
|
||||||
|
_renderDeferredByDrag = false;
|
||||||
|
renderBoards();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-Sync (QS-03): ein offener NewTab drained die Queue, sobald der Worker etwas anhaengt.
|
||||||
|
function bindStorageSync() {
|
||||||
|
if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.onChanged) return;
|
||||||
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
|
// Nur auf die Quick-Save-Queue reagieren — 'boards' schreibt ausschliesslich diese Seite.
|
||||||
|
if (area !== 'local' || !changes.quicksave_pending) return;
|
||||||
|
drainQuickSavePending();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freies Layout (LAYOUT-04): Boards stehen absolut positioniert. Schrumpft das Fenster, koennen
|
||||||
|
// sie ganz aus dem sichtbaren Bereich rutschen. renderBoards() klemmt die --board-x/--board-y jedes
|
||||||
|
// Boards beim Aufbau gegen die aktuelle Viewport — ein simpler Re-Render holt sie also zurueck.
|
||||||
|
// Debounce (150ms), damit kontinuierliches Resizen nicht hunderte Renders ausloest. Waehrend eines
|
||||||
|
// aktiven Drags NICHT neu rendern: renderBoards->replaceChildren wuerde den laufenden Drag abreissen.
|
||||||
|
function bindBoardResizeReclamp() {
|
||||||
|
let resizeTimer = null;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
if (document.querySelector('.board.dragging, .bm-item.dragging-source')) return;
|
||||||
|
renderBoards();
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — background.js
|
||||||
|
Quick-Save Background fuer Chrome (Service-Worker)
|
||||||
|
UND Firefox (Event-Page). Kein DOM/window. Listener
|
||||||
|
synchron auf Top-Level. Geteilte Logik via importScripts.
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
// Geteiltes DOM-freies Helfer-Modul aus Phase 1: ensureInbox(boards), uid(), normalizeBookmark(...).
|
||||||
|
// Chrome-Service-Worker laedt es via importScripts. Firefox-Event-Page hat KEIN importScripts —
|
||||||
|
// dort kommt das Modul ueber background.scripts (manifest.firefox.json) in den Scope, ensureInbox
|
||||||
|
// ist dann schon definiert. Der Guard verhindert den ReferenceError in der Event-Page.
|
||||||
|
if (typeof importScripts === 'function' && typeof ensureInbox === 'undefined') {
|
||||||
|
importScripts('quicksave-core.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
// chrome.storage.local-Lese-/Schreib-Helfer als Promises (kein Store-Modul im Worker,
|
||||||
|
// das ist DOM/Seiten-gebunden). Identisches Verhalten: get -> Wert oder null.
|
||||||
|
function bgGet(key) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
chrome.storage.local.get([key], r => resolve(r[key] ?? null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bgSet(key, value) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.storage.local.set({ [key]: value }, () => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
reject(new Error(chrome.runtime.lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurze Badge-Bestaetigung, dann automatisch wieder leeren. color optional (Default gruen).
|
||||||
|
function flashBadge(text, color) {
|
||||||
|
chrome.action.setBadgeText({ text });
|
||||||
|
// Hintergrundfarbe optional, ohne extra Permission moeglich.
|
||||||
|
if (chrome.action.setBadgeBackgroundColor) {
|
||||||
|
chrome.action.setBadgeBackgroundColor({ color: color || '#1f9d55' });
|
||||||
|
}
|
||||||
|
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interne/nicht speicherbare Seiten (Browser-UI, Extension-Seiten) — kein sinnvolles Bookmark.
|
||||||
|
const UNSAVEABLE_URL = /^(chrome|chrome-extension|about|edge|opera|moz-extension|brave|vivaldi|view-source|devtools):/i;
|
||||||
|
|
||||||
|
// Quick-Save: aktiven Tab in die Pending-Queue haengen — NICHT boards schreiben.
|
||||||
|
// Datensicherheit (Phase-4-Review 2b): boards schreibt ausschliesslich die NewTab-Seite. Der Worker
|
||||||
|
// haengt nur an 'quicksave_pending' an; die Seite drained die Queue in die Inbox. So koennen Worker
|
||||||
|
// und Seite sich nicht im boards-Array gegenseitig ueberschreiben (kein Lost-Update bestehender Daten).
|
||||||
|
async function quickSaveActiveTab() {
|
||||||
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
const tab = tabs && tabs[0];
|
||||||
|
if (!tab || !tab.url || UNSAVEABLE_URL.test(tab.url)) {
|
||||||
|
// Kein speicherbarer Tab: kurzer roter Marker (langer Text wird im Badge abgeschnitten).
|
||||||
|
flashBadge('×', '#c0392b');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// read-modify-write nur auf der EIGENEN Queue (bgGet/bgSet sind via quickSaveChain serialisiert).
|
||||||
|
const pending = (await bgGet('quicksave_pending')) ?? [];
|
||||||
|
pending.push({ id: uid(), title: tab.title || tab.url, url: tab.url });
|
||||||
|
await bgSet('quicksave_pending', pending);
|
||||||
|
flashBadge(chrome.i18n.getMessage('quickSaveBadge'));
|
||||||
|
} catch (e) {
|
||||||
|
// Quota o.ae.: Badge zeigt nichts Gruenes, Fehler in die Worker-Konsole.
|
||||||
|
console.error('Quick-Save fehlgeschlagen:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick-Saves serialisieren: zwei schnelle Tastendruecke (Key-Repeat) wuerden sonst parallel
|
||||||
|
// read-modify-write machen und sich gegenseitig ueberschreiben (lost update). Promise-Kette
|
||||||
|
// sorgt fuer sequentielle Ausfuehrung. Listener bleibt SYNCHRON auf Top-Level registriert.
|
||||||
|
let quickSaveChain = Promise.resolve();
|
||||||
|
chrome.commands.onCommand.addListener(command => {
|
||||||
|
if (command === 'quick-save') {
|
||||||
|
quickSaveChain = quickSaveChain.then(() => quickSaveActiveTab()).catch(e => console.error('Quick-Save:', e && e.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
+154
-44
@@ -46,6 +46,34 @@ function createPlusSvg() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Erzeugt das Pin-/Reisszwecke-Icon SVG (Position fixieren) — bewusst KEIN Emoji (custom SVG). */
|
||||||
|
function createPinSvg() {
|
||||||
|
return svgEl('svg', { width: '11', height: '12', viewBox: '0 0 24 24', fill: 'currentColor' }, [
|
||||||
|
svgEl('path', { d: 'M16 9V4l1 0c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1l1 0v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z' }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- POS-MIGRATION ----
|
||||||
|
// Boards ohne pos (Altbestand vor v2.3) aus einem Auto-Raster befuellen,
|
||||||
|
// damit sie sich nicht alle auf (0,0) stapeln. Raster orientiert sich am
|
||||||
|
// Wrapper-Padding (110px oben / 40px links) und der Board-Breite.
|
||||||
|
function ensureBoardPositions() {
|
||||||
|
const COL_W = 240 + 14; // --board-width (Desktop) + gap
|
||||||
|
const ROW_H = 220; // grober Board-Hoehen-Schaetzwert fuers Auto-Raster
|
||||||
|
const startX = 40, startY = 110;
|
||||||
|
const cols = Math.max(1, Math.floor((window.innerWidth - startX * 2 + 14) / COL_W));
|
||||||
|
|
||||||
|
let migrated = false;
|
||||||
|
boards.forEach((board, i) => {
|
||||||
|
if (board.pos && typeof board.pos.x === 'number' && typeof board.pos.y === 'number') return;
|
||||||
|
const col = i % cols;
|
||||||
|
const row = Math.floor(i / cols);
|
||||||
|
board.pos = { x: startX + col * COL_W, y: startY + row * ROW_H };
|
||||||
|
migrated = true;
|
||||||
|
});
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- RENDER ----
|
// ---- RENDER ----
|
||||||
function renderBoards() {
|
function renderBoards() {
|
||||||
const wrapper = document.getElementById('boardsWrapper');
|
const wrapper = document.getElementById('boardsWrapper');
|
||||||
@@ -57,26 +85,42 @@ function renderBoards() {
|
|||||||
|
|
||||||
const boardStrong = document.createElement('strong');
|
const boardStrong = document.createElement('strong');
|
||||||
boardStrong.className = 'accent-text';
|
boardStrong.className = 'accent-text';
|
||||||
boardStrong.textContent = '+ Board';
|
boardStrong.textContent = t('boards.add_board');
|
||||||
|
|
||||||
const importStrong = document.createElement('strong');
|
const importStrong = document.createElement('strong');
|
||||||
importStrong.className = 'accent-text';
|
importStrong.className = 'accent-text';
|
||||||
importStrong.textContent = 'Import';
|
importStrong.textContent = t('boards.import');
|
||||||
|
|
||||||
empty.append(
|
empty.append(
|
||||||
'No boards yet. Click ', boardStrong, ' to create one, or use ', importStrong, ' to load your browser bookmarks.'
|
t('boards.empty_state_pre'), boardStrong, t('boards.empty_state_mid'), importStrong, t('boards.empty_state_post')
|
||||||
);
|
);
|
||||||
wrapper.appendChild(empty);
|
wrapper.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
boards.forEach(board => wrapper.appendChild(createBoardEl(board)));
|
// Altbestand ohne pos migrieren (Auto-Raster), danach einmalig speichern.
|
||||||
|
const migrated = ensureBoardPositions();
|
||||||
|
|
||||||
|
boards.forEach(board => {
|
||||||
|
const el = createBoardEl(board);
|
||||||
|
wrapper.appendChild(el);
|
||||||
|
// Position als Custom-Property setzen (nicht inline left/top), damit der Mobil-@media-Reset
|
||||||
|
// sie ueberschreiben kann. Gegen den AKTUELLEN Viewport clampen, damit ein auf breiterem
|
||||||
|
// Fenster platziertes Board nie off-screen (und damit per Drag unerreichbar) rendert.
|
||||||
|
// board.pos bleibt unveraendert -> bei spaeterer Verbreiterung wird die Originalposition wieder erreicht.
|
||||||
|
const cx = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, board.pos.x));
|
||||||
|
const cy = Math.max(48, Math.min(window.innerHeight - el.offsetHeight, board.pos.y));
|
||||||
|
el.style.setProperty('--board-x', cx + 'px');
|
||||||
|
el.style.setProperty('--board-y', cy + 'px');
|
||||||
|
});
|
||||||
initBoardDragDrop();
|
initBoardDragDrop();
|
||||||
|
|
||||||
|
if (migrated) saveBoards();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBoardEl(board) {
|
function createBoardEl(board) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'board' + (board.blurred ? ' blurred' : '');
|
div.className = 'board' + (board.blurred ? ' blurred' : '') + (board.locked ? ' locked' : '');
|
||||||
div.dataset.boardId = board.id;
|
div.dataset.boardId = board.id;
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
@@ -85,7 +129,7 @@ function createBoardEl(board) {
|
|||||||
|
|
||||||
const dragHandle = document.createElement('span');
|
const dragHandle = document.createElement('span');
|
||||||
dragHandle.className = 'board-drag-handle';
|
dragHandle.className = 'board-drag-handle';
|
||||||
dragHandle.title = 'Board verschieben';
|
dragHandle.title = t('boards.drag_title');
|
||||||
dragHandle.appendChild(createDragHandleSvg());
|
dragHandle.appendChild(createDragHandleSvg());
|
||||||
|
|
||||||
const titleSpanHeader = document.createElement('span');
|
const titleSpanHeader = document.createElement('span');
|
||||||
@@ -96,22 +140,34 @@ function createBoardEl(board) {
|
|||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'board-actions';
|
actions.className = 'board-actions';
|
||||||
|
|
||||||
|
const btnLock = document.createElement('button');
|
||||||
|
btnLock.className = 'board-action-btn btn-lock-board';
|
||||||
|
btnLock.title = board.locked ? t('boards.unlock') : t('boards.lock');
|
||||||
|
btnLock.appendChild(createPinSvg());
|
||||||
|
|
||||||
const btnBlur = document.createElement('button');
|
const btnBlur = document.createElement('button');
|
||||||
btnBlur.className = 'board-action-btn btn-blur-board';
|
btnBlur.className = 'board-action-btn btn-blur-board';
|
||||||
btnBlur.title = board.blurred ? 'Unblur' : 'Blur (privat)';
|
btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur');
|
||||||
btnBlur.textContent = '\uD83D\uDD12';
|
btnBlur.textContent = '\uD83D\uDD12';
|
||||||
|
|
||||||
const btnRename = document.createElement('button');
|
const btnRename = document.createElement('button');
|
||||||
btnRename.className = 'board-action-btn btn-rename-board';
|
btnRename.className = 'board-action-btn btn-rename-board';
|
||||||
btnRename.title = 'Umbenennen';
|
btnRename.title = t('boards.rename');
|
||||||
btnRename.textContent = '\u270E';
|
btnRename.textContent = '\u270E';
|
||||||
|
|
||||||
const btnDelete = document.createElement('button');
|
// Das feste Inbox-Board (Quick-Save-Ziel) darf nicht geloescht werden \u2014 kein Delete-Button.
|
||||||
btnDelete.className = 'board-action-btn btn-delete-board';
|
const btnDelete = board.id === 'inbox' ? null : document.createElement('button');
|
||||||
btnDelete.title = 'Löschen';
|
if (btnDelete) {
|
||||||
btnDelete.textContent = '\u2715';
|
btnDelete.className = 'board-action-btn btn-delete-board';
|
||||||
|
btnDelete.title = t('boards.delete');
|
||||||
|
btnDelete.textContent = '\u2715';
|
||||||
|
}
|
||||||
|
|
||||||
actions.append(btnBlur, btnRename, btnDelete);
|
if (btnDelete) {
|
||||||
|
actions.append(btnLock, btnBlur, btnRename, btnDelete);
|
||||||
|
} else {
|
||||||
|
actions.append(btnLock, btnBlur, btnRename);
|
||||||
|
}
|
||||||
header.append(dragHandle, titleSpanHeader, actions);
|
header.append(dragHandle, titleSpanHeader, actions);
|
||||||
|
|
||||||
// Blur-Overlay
|
// Blur-Overlay
|
||||||
@@ -119,18 +175,28 @@ function createBoardEl(board) {
|
|||||||
blurOverlay.className = 'board-blur-overlay';
|
blurOverlay.className = 'board-blur-overlay';
|
||||||
div.appendChild(blurOverlay);
|
div.appendChild(blurOverlay);
|
||||||
|
|
||||||
|
btnLock.addEventListener('click', async e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Position fixieren: blendet via .board.locked den Drag-Handle aus (CSS) und der onDown-Guard
|
||||||
|
// in drag.js verweigert zusaetzlich den Drag. Reiner Klassen-Toggle, kein Re-Render noetig.
|
||||||
|
board.locked = !board.locked;
|
||||||
|
div.classList.toggle('locked', board.locked);
|
||||||
|
btnLock.title = board.locked ? t('boards.unlock') : t('boards.lock');
|
||||||
|
await saveBoards();
|
||||||
|
});
|
||||||
|
|
||||||
btnBlur.addEventListener('click', async e => {
|
btnBlur.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
board.blurred = !board.blurred;
|
board.blurred = !board.blurred;
|
||||||
div.classList.toggle('blurred', board.blurred);
|
div.classList.toggle('blurred', board.blurred);
|
||||||
btnBlur.title = board.blurred ? 'Unblur' : 'Blur (privat)';
|
btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur');
|
||||||
await saveBoards();
|
await saveBoards();
|
||||||
});
|
});
|
||||||
|
|
||||||
blurOverlay.addEventListener('click', async () => {
|
blurOverlay.addEventListener('click', async () => {
|
||||||
board.blurred = false;
|
board.blurred = false;
|
||||||
div.classList.remove('blurred');
|
div.classList.remove('blurred');
|
||||||
btnBlur.title = 'Blur (privat)';
|
btnBlur.title = t('boards.blur');
|
||||||
await saveBoards();
|
await saveBoards();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,15 +210,29 @@ function createBoardEl(board) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
btnDelete.addEventListener('click', async e => {
|
if (btnDelete) btnDelete.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const ok = await HellionDialog.confirm(
|
const ok = await HellionDialog.confirm(
|
||||||
`Board "${board.title}" wirklich löschen?`,
|
t('boards.delete_confirm', { title: board.title }),
|
||||||
{ type: 'danger', title: 'Board löschen', confirmText: 'Löschen' }
|
{ type: 'danger', title: t('boards.delete_confirm.title'), confirmText: t('boards.delete') }
|
||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
boards = boards.filter(b => b.id !== board.id);
|
// Ganzes board-Objekt (inkl. bookmarks UND blurred-Flag, CR-01) in den Papierkorb.
|
||||||
await saveBoards();
|
// type:'board', kein originBoardId (Board hat keine Herkunft, Restore legt es direkt in boards[]).
|
||||||
|
// Datensicherheit: ZUERST Trash sichern (saveTrash), DANN Loeschung committen (saveBoards) —
|
||||||
|
// bei Quota-Reject bleibt das Board in boards[], kein Datenverlust.
|
||||||
|
const trashEntry = pushToTrash({ item: board, type: 'board', originBoardId: null });
|
||||||
|
try {
|
||||||
|
await saveTrash();
|
||||||
|
boards = boards.filter(b => b.id !== board.id);
|
||||||
|
await saveBoards();
|
||||||
|
} catch (err) {
|
||||||
|
// Save fehlgeschlagen (z.B. Quota genau zwischen den Writes): auf den Vor-Loesch-Stand
|
||||||
|
// zurueckrollen, damit In-Memory und Storage konsistent bleiben (kein Reload-Duplikat).
|
||||||
|
trash = trash.filter(t => t !== trashEntry);
|
||||||
|
if (!boards.some(b => b.id === board.id)) boards.push(board);
|
||||||
|
console.error('Board-Loeschen fehlgeschlagen, zurueckgerollt:', err && err.message);
|
||||||
|
}
|
||||||
renderBoards();
|
renderBoards();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -180,16 +260,16 @@ function createBoardEl(board) {
|
|||||||
let hiddenEls = [];
|
let hiddenEls = [];
|
||||||
const showMoreBtn = document.createElement('button');
|
const showMoreBtn = document.createElement('button');
|
||||||
showMoreBtn.className = 'show-more-btn';
|
showMoreBtn.className = 'show-more-btn';
|
||||||
showMoreBtn.textContent = `Show ${hidden.length} more…`;
|
showMoreBtn.textContent = t('boards.show_more', { count: hidden.length });
|
||||||
showMoreBtn.addEventListener('click', () => {
|
showMoreBtn.addEventListener('click', () => {
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
hidden.forEach(bm => { const el = createBmEl(bm); hiddenEls.push(el); list.appendChild(el); });
|
hidden.forEach(bm => { const el = createBmEl(bm); hiddenEls.push(el); list.appendChild(el); });
|
||||||
showMoreBtn.textContent = 'Show less';
|
showMoreBtn.textContent = t('boards.show_less');
|
||||||
expanded = true;
|
expanded = true;
|
||||||
} else {
|
} else {
|
||||||
hiddenEls.forEach(el => el.remove());
|
hiddenEls.forEach(el => el.remove());
|
||||||
hiddenEls = [];
|
hiddenEls = [];
|
||||||
showMoreBtn.textContent = `Show ${hidden.length} more…`;
|
showMoreBtn.textContent = t('boards.show_more', { count: hidden.length });
|
||||||
expanded = false;
|
expanded = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -200,7 +280,7 @@ function createBoardEl(board) {
|
|||||||
const addBtn = document.createElement('button');
|
const addBtn = document.createElement('button');
|
||||||
addBtn.className = 'add-bm-btn';
|
addBtn.className = 'add-bm-btn';
|
||||||
addBtn.appendChild(createPlusSvg());
|
addBtn.appendChild(createPlusSvg());
|
||||||
addBtn.append(' Add link');
|
addBtn.append(t('boards.add_link'));
|
||||||
addBtn.addEventListener('click', () => openAddBookmarkModal(board.id));
|
addBtn.addEventListener('click', () => openAddBookmarkModal(board.id));
|
||||||
div.appendChild(addBtn);
|
div.appendChild(addBtn);
|
||||||
|
|
||||||
@@ -214,20 +294,15 @@ function createBmEl(bm) {
|
|||||||
li.dataset.bmId = bm.id;
|
li.dataset.bmId = bm.id;
|
||||||
li.dataset.bmUrl = bm.url;
|
li.dataset.bmUrl = bm.url;
|
||||||
li.draggable = true;
|
li.draggable = true;
|
||||||
|
li.setAttribute('role', 'link');
|
||||||
|
li.setAttribute('tabindex', '0');
|
||||||
|
li.setAttribute('aria-label', bm.title);
|
||||||
|
|
||||||
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';
|
||||||
@@ -243,11 +318,10 @@ function createBmEl(bm) {
|
|||||||
|
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.className = 'bm-delete';
|
deleteBtn.className = 'bm-delete';
|
||||||
deleteBtn.title = 'Entfernen';
|
deleteBtn.title = t('boards.remove_bookmark');
|
||||||
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);
|
||||||
|
|
||||||
@@ -260,12 +334,28 @@ function bindBoardListEvents(list, board) {
|
|||||||
const bmItem = e.target.closest('.bm-item');
|
const bmItem = e.target.closest('.bm-item');
|
||||||
if (!bmItem) return;
|
if (!bmItem) return;
|
||||||
|
|
||||||
// Delete-Button geklickt
|
// Delete-Button geklickt: kein Confirm (wie bisher), aber nicht mehr hart loeschen —
|
||||||
|
// das Bookmark wandert in den Papierkorb (30 Tage, TRASH-01). Erst per find() greifen,
|
||||||
|
// dann mit Herkunft (originBoardId), type und Zeitstempel ins trash[] pushen.
|
||||||
if (e.target.closest('.bm-delete')) {
|
if (e.target.closest('.bm-delete')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const bmId = bmItem.dataset.bmId;
|
const bmId = bmItem.dataset.bmId;
|
||||||
board.bookmarks = board.bookmarks.filter(b => b.id !== bmId);
|
const removed = board.bookmarks.find(b => b.id === bmId);
|
||||||
await saveBoards();
|
if (removed) {
|
||||||
|
// Datensicherheit: ZUERST den Trash-Klon persistieren, DANN die Loeschung committen.
|
||||||
|
// Falls saveTrash() (Quota) rejectet, ist das Original noch in boards[] -> kein Verlust.
|
||||||
|
const trashEntry = pushToTrash({ item: removed, type: 'bookmark', originBoardId: board.id });
|
||||||
|
try {
|
||||||
|
await saveTrash();
|
||||||
|
board.bookmarks = board.bookmarks.filter(b => b.id !== bmId);
|
||||||
|
await saveBoards();
|
||||||
|
} catch (err) {
|
||||||
|
// Save fehlgeschlagen: auf den Vor-Loesch-Stand zurueckrollen (kein Reload-Duplikat).
|
||||||
|
trash = trash.filter(t => t !== trashEntry);
|
||||||
|
if (!board.bookmarks.some(b => b.id === bmId)) board.bookmarks.push(removed);
|
||||||
|
console.error('Bookmark-Loeschen fehlgeschlagen, zurueckgerollt:', err && err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
renderBoards();
|
renderBoards();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -276,11 +366,31 @@ function bindBoardListEvents(list, board) {
|
|||||||
window.open(url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
|
window.open(url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tastatur: Enter oeffnet den Bookmark wie ein Klick. role="link" erwartet
|
||||||
|
// nur Enter (Space ist Button-Semantik). Der Delete-Button bleibt ein echter
|
||||||
|
// <button> und feuert seinen eigenen Klick ueber Space/Enter selbst.
|
||||||
|
list.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
const bmItem = e.target.closest('.bm-item');
|
||||||
|
if (!bmItem || e.target !== bmItem) return; // nur wenn der li selbst fokussiert ist
|
||||||
|
e.preventDefault();
|
||||||
|
const url = bmItem.dataset.bmUrl;
|
||||||
|
if (url) {
|
||||||
|
window.open(url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- MODALS ----
|
// ---- MODALS ----
|
||||||
function openModal(id) { document.getElementById(id).classList.add('active'); }
|
// reduced-motion kappt das Fade ueber den ungeschichteten @media-Block.
|
||||||
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
// Feature-Detection-Fallback (Firefox < 144): instant.
|
||||||
|
function openModal(id) {
|
||||||
|
withViewTransition(() => document.getElementById(id).classList.add('active'));
|
||||||
|
}
|
||||||
|
function closeModal(id) {
|
||||||
|
withViewTransition(() => document.getElementById(id).classList.remove('active'));
|
||||||
|
}
|
||||||
|
|
||||||
function openAddBoardModal() {
|
function openAddBoardModal() {
|
||||||
document.getElementById('newBoardName').value = '';
|
document.getElementById('newBoardName').value = '';
|
||||||
|
|||||||
+25
-23
@@ -42,8 +42,8 @@ const BrowserBookmarkImport = {
|
|||||||
tree = await api.getTree();
|
tree = await api.getTree();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
'Zugriff auf Browser-Lesezeichen nicht möglich. Stelle sicher, dass die Extension die nötigen Berechtigungen hat.',
|
t('bm_import.no_access'),
|
||||||
{ type: 'warning', title: 'Lesezeichen-Import' }
|
{ type: 'warning', title: t('bm_import.title') }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,8 +51,8 @@ const BrowserBookmarkImport = {
|
|||||||
const folders = this._extractFolders(tree[0]);
|
const folders = this._extractFolders(tree[0]);
|
||||||
if (folders.length === 0) {
|
if (folders.length === 0) {
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
'Keine Lesezeichen-Ordner gefunden.',
|
t('bm_import.no_folders'),
|
||||||
{ type: 'warning', title: 'Lesezeichen-Import' }
|
{ type: 'warning', title: t('bm_import.title') }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ const BrowserBookmarkImport = {
|
|||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
id: child.id,
|
id: child.id,
|
||||||
title: child.title || 'Unbenannt',
|
title: child.title || t('bm_import.unnamed'),
|
||||||
depth: depth,
|
depth: depth,
|
||||||
bookmarkCount: bookmarkCount,
|
bookmarkCount: bookmarkCount,
|
||||||
subfolderCount: subfolderCount,
|
subfolderCount: subfolderCount,
|
||||||
@@ -114,7 +114,7 @@ const BrowserBookmarkImport = {
|
|||||||
header.className = 'bm-import-header';
|
header.className = 'bm-import-header';
|
||||||
|
|
||||||
const title = document.createElement('span');
|
const title = document.createElement('span');
|
||||||
title.textContent = 'Browser-Lesezeichen importieren';
|
title.textContent = t('bm_import.modal_title');
|
||||||
header.appendChild(title);
|
header.appendChild(title);
|
||||||
|
|
||||||
const closeBtn = document.createElement('button');
|
const closeBtn = document.createElement('button');
|
||||||
@@ -128,7 +128,7 @@ const BrowserBookmarkImport = {
|
|||||||
// Info
|
// Info
|
||||||
const info = document.createElement('div');
|
const info = document.createElement('div');
|
||||||
info.className = 'bm-import-info';
|
info.className = 'bm-import-info';
|
||||||
info.textContent = 'Wähle die Ordner aus, die als Boards importiert werden sollen. Jeder Ordner wird ein eigenes Board.';
|
info.textContent = t('bm_import.info');
|
||||||
modal.appendChild(info);
|
modal.appendChild(info);
|
||||||
|
|
||||||
// Ordner-Liste
|
// Ordner-Liste
|
||||||
@@ -155,13 +155,13 @@ const BrowserBookmarkImport = {
|
|||||||
meta.className = 'bm-import-folder-meta';
|
meta.className = 'bm-import-folder-meta';
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (folder.bookmarkCount > 0) {
|
if (folder.bookmarkCount > 0) {
|
||||||
parts.push(folder.bookmarkCount + ' Link' + (folder.bookmarkCount !== 1 ? 's' : ''));
|
parts.push(t('bm_import.link_count', { count: folder.bookmarkCount }));
|
||||||
}
|
}
|
||||||
if (folder.subfolderCount > 0) {
|
if (folder.subfolderCount > 0) {
|
||||||
parts.push(folder.subfolderCount + ' Ordner');
|
parts.push(t('bm_import.folder_count', { count: folder.subfolderCount }));
|
||||||
}
|
}
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
parts.push('leer');
|
parts.push(t('bm_import.empty'));
|
||||||
}
|
}
|
||||||
meta.textContent = parts.join(', ');
|
meta.textContent = parts.join(', ');
|
||||||
row.appendChild(meta);
|
row.appendChild(meta);
|
||||||
@@ -177,18 +177,18 @@ const BrowserBookmarkImport = {
|
|||||||
|
|
||||||
const selectAll = document.createElement('button');
|
const selectAll = document.createElement('button');
|
||||||
selectAll.className = 'btn-secondary';
|
selectAll.className = 'btn-secondary';
|
||||||
selectAll.textContent = 'Alle auswählen';
|
selectAll.textContent = t('bm_import.select_all');
|
||||||
selectAll.addEventListener('click', () => {
|
selectAll.addEventListener('click', () => {
|
||||||
const boxes = list.querySelectorAll('.bm-import-checkbox');
|
const boxes = list.querySelectorAll('.bm-import-checkbox');
|
||||||
const allChecked = Array.from(boxes).every(function(cb) { return cb.checked; });
|
const allChecked = Array.from(boxes).every(function(cb) { return cb.checked; });
|
||||||
boxes.forEach(function(cb) { cb.checked = !allChecked; });
|
boxes.forEach(function(cb) { cb.checked = !allChecked; });
|
||||||
selectAll.textContent = allChecked ? 'Alle auswählen' : 'Alle abwählen';
|
selectAll.textContent = allChecked ? t('bm_import.select_all') : t('bm_import.deselect_all');
|
||||||
});
|
});
|
||||||
footer.appendChild(selectAll);
|
footer.appendChild(selectAll);
|
||||||
|
|
||||||
const importBtn = document.createElement('button');
|
const importBtn = document.createElement('button');
|
||||||
importBtn.className = 'btn-primary';
|
importBtn.className = 'btn-primary';
|
||||||
importBtn.textContent = 'Importieren';
|
importBtn.textContent = t('bm_import.import_btn');
|
||||||
importBtn.addEventListener('click', () => this._importSelected(folders));
|
importBtn.addEventListener('click', () => this._importSelected(folders));
|
||||||
footer.appendChild(importBtn);
|
footer.appendChild(importBtn);
|
||||||
|
|
||||||
@@ -196,16 +196,18 @@ const BrowserBookmarkImport = {
|
|||||||
overlay.appendChild(modal);
|
overlay.appendChild(modal);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
// Animation
|
// View-Transition-Fade
|
||||||
requestAnimationFrame(() => overlay.classList.add('active'));
|
withViewTransition(() => overlay.classList.add('active'));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Schliesst das Modal */
|
/** Schliesst das Modal */
|
||||||
_closeModal() {
|
_closeModal() {
|
||||||
const overlay = document.getElementById('bmImportOverlay');
|
const overlay = document.getElementById('bmImportOverlay');
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
overlay.classList.remove('active');
|
withViewTransition(() => {
|
||||||
setTimeout(() => overlay.remove(), 250);
|
overlay.classList.remove('active');
|
||||||
|
overlay.remove();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,8 +218,8 @@ const BrowserBookmarkImport = {
|
|||||||
const checkboxes = document.querySelectorAll('.bm-import-checkbox:checked');
|
const checkboxes = document.querySelectorAll('.bm-import-checkbox:checked');
|
||||||
if (checkboxes.length === 0) {
|
if (checkboxes.length === 0) {
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
'Bitte wähle mindestens einen Ordner aus.',
|
t('bm_import.no_selection'),
|
||||||
{ type: 'warning', title: 'Lesezeichen-Import' }
|
{ type: 'warning', title: t('bm_import.title') }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -289,15 +291,15 @@ const BrowserBookmarkImport = {
|
|||||||
|
|
||||||
// Ergebnis-Dialog
|
// Ergebnis-Dialog
|
||||||
const lines = [];
|
const lines = [];
|
||||||
lines.push(boardsCreated + ' Board' + (boardsCreated !== 1 ? 's' : '') + ' erstellt');
|
lines.push(t('bm_import.boards_created', { count: boardsCreated }));
|
||||||
lines.push(totalImported + ' Lesezeichen importiert');
|
lines.push(t('bm_import.bookmarks_imported', { count: totalImported }));
|
||||||
if (totalSkipped > 0) {
|
if (totalSkipped > 0) {
|
||||||
lines.push(totalSkipped + ' Duplikat' + (totalSkipped !== 1 ? 'e' : '') + ' übersprungen');
|
lines.push(t('bm_import.duplicates_skipped', { count: totalSkipped }));
|
||||||
}
|
}
|
||||||
|
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
lines.join('\n'),
|
lines.join('\n'),
|
||||||
{ type: 'success', title: 'Import abgeschlossen' }
|
{ type: 'success', title: t('bm_import.success_title') }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — calc-converter.js
|
||||||
|
Unit-Converter Modus für Calculator Widget
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CATEGORIES = {
|
||||||
|
length: {
|
||||||
|
titleKey: 'calculator.conv.cat.length',
|
||||||
|
baseUnit: 'm',
|
||||||
|
units: {
|
||||||
|
mm: { toBase: v => v / 1000, fromBase: v => v * 1000 },
|
||||||
|
cm: { toBase: v => v / 100, fromBase: v => v * 100 },
|
||||||
|
m: { toBase: v => v, fromBase: v => v },
|
||||||
|
km: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||||
|
in: { toBase: v => v * 0.0254, fromBase: v => v / 0.0254 },
|
||||||
|
ft: { toBase: v => v * 0.3048, fromBase: v => v / 0.3048 },
|
||||||
|
yd: { toBase: v => v * 0.9144, fromBase: v => v / 0.9144 },
|
||||||
|
mi: { toBase: v => v * 1609.344, fromBase: v => v / 1609.344 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
weight: {
|
||||||
|
titleKey: 'calculator.conv.cat.weight',
|
||||||
|
baseUnit: 'g',
|
||||||
|
units: {
|
||||||
|
mg: { toBase: v => v / 1000, fromBase: v => v * 1000 },
|
||||||
|
g: { toBase: v => v, fromBase: v => v },
|
||||||
|
kg: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||||
|
t: { toBase: v => v * 1000000, fromBase: v => v / 1000000 },
|
||||||
|
oz: { toBase: v => v * 28.3495, fromBase: v => v / 28.3495 },
|
||||||
|
lb: { toBase: v => v * 453.592, fromBase: v => v / 453.592 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
temperature: {
|
||||||
|
titleKey: 'calculator.conv.cat.temperature',
|
||||||
|
baseUnit: null,
|
||||||
|
units: { '\u00B0C': null, '\u00B0F': null, 'K': null },
|
||||||
|
convert(value, from, to) {
|
||||||
|
if (from === to) return value;
|
||||||
|
const key = from + '_' + to;
|
||||||
|
const conversions = {
|
||||||
|
'\u00B0C_\u00B0F': v => (v * 9 / 5) + 32,
|
||||||
|
'\u00B0C_K': v => v + 273.15,
|
||||||
|
'\u00B0F_\u00B0C': v => (v - 32) * 5 / 9,
|
||||||
|
'\u00B0F_K': v => (v - 32) * 5 / 9 + 273.15,
|
||||||
|
'K_\u00B0C': v => v - 273.15,
|
||||||
|
'K_\u00B0F': v => (v - 273.15) * 9 / 5 + 32
|
||||||
|
};
|
||||||
|
const fn = conversions[key];
|
||||||
|
return fn ? fn(value) : null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
volume: {
|
||||||
|
titleKey: 'calculator.conv.cat.volume',
|
||||||
|
baseUnit: 'ml',
|
||||||
|
units: {
|
||||||
|
ml: { toBase: v => v, fromBase: v => v },
|
||||||
|
L: { toBase: v => v * 1000, fromBase: v => v / 1000 },
|
||||||
|
'm\u00B3':{ toBase: v => v * 1000000, fromBase: v => v / 1000000 },
|
||||||
|
'gal(US)':{ toBase: v => v * 3785.41, fromBase: v => v / 3785.41 },
|
||||||
|
'gal(UK)':{ toBase: v => v * 4546.09, fromBase: v => v / 4546.09 },
|
||||||
|
'ft\u00B3':{ toBase: v => v * 28316.8, fromBase: v => v / 28316.8 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
speed: {
|
||||||
|
titleKey: 'calculator.conv.cat.speed',
|
||||||
|
baseUnit: 'm/s',
|
||||||
|
units: {
|
||||||
|
'm/s': { toBase: v => v, fromBase: v => v },
|
||||||
|
'km/h': { toBase: v => v / 3.6, fromBase: v => v * 3.6 },
|
||||||
|
'mph': { toBase: v => v * 0.44704, fromBase: v => v / 0.44704 },
|
||||||
|
'kn': { toBase: v => v * 0.514444, fromBase: v => v / 0.514444 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
area: {
|
||||||
|
titleKey: 'calculator.conv.cat.area',
|
||||||
|
baseUnit: 'm\u00B2',
|
||||||
|
units: {
|
||||||
|
'mm\u00B2': { toBase: v => v / 1000000, fromBase: v => v * 1000000 },
|
||||||
|
'cm\u00B2': { toBase: v => v / 10000, fromBase: v => v * 10000 },
|
||||||
|
'm\u00B2': { toBase: v => v, fromBase: v => v },
|
||||||
|
'km\u00B2': { toBase: v => v * 1000000, fromBase: v => v / 1000000 },
|
||||||
|
'ha': { toBase: v => v * 10000, fromBase: v => v / 10000 },
|
||||||
|
'acre': { toBase: v => v * 4046.86, fromBase: v => v / 4046.86 },
|
||||||
|
'ft\u00B2': { toBase: v => v * 0.092903, fromBase: v => v / 0.092903 },
|
||||||
|
'in\u00B2': { toBase: v => v * 0.00064516, fromBase: v => v / 0.00064516 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = ['length', 'weight', 'temperature', 'volume', 'speed', 'area'];
|
||||||
|
|
||||||
|
let _currentCategory = 'length';
|
||||||
|
let _fromUnit = 'cm';
|
||||||
|
let _toUnit = 'in';
|
||||||
|
let _fromInput = null;
|
||||||
|
let _toInput = null;
|
||||||
|
let _refEl = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a value from one unit to another within the current category.
|
||||||
|
* @param {number} value
|
||||||
|
* @param {string} from
|
||||||
|
* @param {string} to
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function convert(value, from, to) {
|
||||||
|
const cat = CATEGORIES[_currentCategory];
|
||||||
|
if (!cat) return null;
|
||||||
|
if (cat.convert) return cat.convert(value, from, to);
|
||||||
|
const fromDef = cat.units[from];
|
||||||
|
const toDef = cat.units[to];
|
||||||
|
if (!fromDef || !toDef) return null;
|
||||||
|
const base = fromDef.toBase(value);
|
||||||
|
return toDef.fromBase(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculates the output field and reference lines based on current input.
|
||||||
|
*/
|
||||||
|
function recalc() {
|
||||||
|
if (!_fromInput || !_toInput) return;
|
||||||
|
const val = parseFloat(_fromInput.value);
|
||||||
|
if (isNaN(val)) {
|
||||||
|
_toInput.value = '';
|
||||||
|
updateReference();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = convert(val, _fromUnit, _toUnit);
|
||||||
|
if (result === null) {
|
||||||
|
_toInput.value = '';
|
||||||
|
} else {
|
||||||
|
_toInput.value = Calculator._formatResult(result);
|
||||||
|
}
|
||||||
|
updateReference();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the reference conversion lines below the inputs.
|
||||||
|
*/
|
||||||
|
function updateReference() {
|
||||||
|
if (!_refEl) return;
|
||||||
|
_refEl.textContent = '';
|
||||||
|
const r1 = convert(1, _fromUnit, _toUnit);
|
||||||
|
const r2 = convert(1, _toUnit, _fromUnit);
|
||||||
|
if (r1 !== null) {
|
||||||
|
const line1 = document.createElement('div');
|
||||||
|
line1.textContent = '1 ' + _fromUnit + ' = ' + Calculator._formatResult(r1) + ' ' + _toUnit;
|
||||||
|
_refEl.appendChild(line1);
|
||||||
|
}
|
||||||
|
if (r2 !== null) {
|
||||||
|
const line2 = document.createElement('div');
|
||||||
|
line2.textContent = '1 ' + _toUnit + ' = ' + Calculator._formatResult(r2) + ' ' + _fromUnit;
|
||||||
|
_refEl.appendChild(line2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates a unit <select> element with options for the current category.
|
||||||
|
* @param {HTMLSelectElement} selectEl
|
||||||
|
* @param {string} selectedUnit
|
||||||
|
*/
|
||||||
|
function populateUnitSelect(selectEl, selectedUnit) {
|
||||||
|
while (selectEl.firstChild) {
|
||||||
|
selectEl.removeChild(selectEl.firstChild);
|
||||||
|
}
|
||||||
|
const cat = CATEGORIES[_currentCategory];
|
||||||
|
if (!cat) return;
|
||||||
|
const units = Object.keys(cat.units);
|
||||||
|
units.forEach(unit => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = unit;
|
||||||
|
opt.textContent = unit;
|
||||||
|
if (unit === selectedUnit) opt.selected = true;
|
||||||
|
selectEl.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns sensible default from/to units for a given category key.
|
||||||
|
* @param {string} catKey
|
||||||
|
* @returns {{ from: string, to: string }}
|
||||||
|
*/
|
||||||
|
function getDefaultUnits(catKey) {
|
||||||
|
const defaults = {
|
||||||
|
length: { from: 'cm', to: 'in' },
|
||||||
|
weight: { from: 'kg', to: 'lb' },
|
||||||
|
temperature: { from: '\u00B0C', to: '\u00B0F' },
|
||||||
|
volume: { from: 'L', to: 'gal(US)' },
|
||||||
|
speed: { from: 'km/h', to: 'mph' },
|
||||||
|
area: { from: 'm\u00B2', to: 'ft\u00B2' }
|
||||||
|
};
|
||||||
|
return defaults[catKey] || { from: Object.keys(CATEGORIES[catKey].units)[0], to: Object.keys(CATEGORIES[catKey].units)[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads persisted converter state from storage.
|
||||||
|
*/
|
||||||
|
async function loadState() {
|
||||||
|
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||||
|
if (data && data.calculator && data.calculator.converter) {
|
||||||
|
const s = data.calculator.converter;
|
||||||
|
if (s.lastCategory && CATEGORIES[s.lastCategory]) _currentCategory = s.lastCategory;
|
||||||
|
if (s.fromUnit) _fromUnit = s.fromUnit;
|
||||||
|
if (s.toUnit) _toUnit = s.toUnit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists current converter state to storage (read-before-write).
|
||||||
|
*/
|
||||||
|
async function saveState() {
|
||||||
|
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||||
|
if (!data.calculator) data.calculator = {};
|
||||||
|
data.calculator.converter = {
|
||||||
|
lastCategory: _currentCategory,
|
||||||
|
fromUnit: _fromUnit,
|
||||||
|
toUnit: _toUnit
|
||||||
|
};
|
||||||
|
await Store.set(Calculator.STORAGE_KEY, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the converter UI and appends it to the widget body element.
|
||||||
|
* @param {HTMLElement} bodyEl
|
||||||
|
*/
|
||||||
|
function buildUI(bodyEl) {
|
||||||
|
const catSelect = document.createElement('select');
|
||||||
|
catSelect.className = 'calc-conv-select';
|
||||||
|
|
||||||
|
CATEGORY_ORDER.forEach(catKey => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = catKey;
|
||||||
|
opt.textContent = t(CATEGORIES[catKey].titleKey);
|
||||||
|
if (catKey === _currentCategory) opt.selected = true;
|
||||||
|
catSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromRow = document.createElement('div');
|
||||||
|
fromRow.className = 'calc-conv-row';
|
||||||
|
|
||||||
|
_fromInput = document.createElement('input');
|
||||||
|
_fromInput.type = 'number';
|
||||||
|
_fromInput.className = 'calc-conv-input';
|
||||||
|
_fromInput.placeholder = '0';
|
||||||
|
_fromInput.step = 'any';
|
||||||
|
|
||||||
|
const fromSelect = document.createElement('select');
|
||||||
|
fromSelect.className = 'calc-conv-unit';
|
||||||
|
populateUnitSelect(fromSelect, _fromUnit);
|
||||||
|
|
||||||
|
fromRow.append(_fromInput, fromSelect);
|
||||||
|
|
||||||
|
const swapBtn = document.createElement('button');
|
||||||
|
swapBtn.type = 'button';
|
||||||
|
swapBtn.className = 'calc-conv-swap';
|
||||||
|
swapBtn.textContent = '\u21C5';
|
||||||
|
swapBtn.title = t('calculator.conv.swap');
|
||||||
|
|
||||||
|
const toRow = document.createElement('div');
|
||||||
|
toRow.className = 'calc-conv-row';
|
||||||
|
|
||||||
|
_toInput = document.createElement('input');
|
||||||
|
_toInput.type = 'text';
|
||||||
|
_toInput.className = 'calc-conv-input';
|
||||||
|
_toInput.readOnly = true;
|
||||||
|
_toInput.placeholder = '0';
|
||||||
|
|
||||||
|
const toSelect = document.createElement('select');
|
||||||
|
toSelect.className = 'calc-conv-unit';
|
||||||
|
populateUnitSelect(toSelect, _toUnit);
|
||||||
|
|
||||||
|
toRow.append(_toInput, toSelect);
|
||||||
|
|
||||||
|
_refEl = document.createElement('div');
|
||||||
|
_refEl.className = 'calc-conv-ref';
|
||||||
|
|
||||||
|
_fromInput.addEventListener('input', () => recalc());
|
||||||
|
fromSelect.addEventListener('change', () => {
|
||||||
|
_fromUnit = fromSelect.value;
|
||||||
|
recalc();
|
||||||
|
saveState();
|
||||||
|
});
|
||||||
|
toSelect.addEventListener('change', () => {
|
||||||
|
_toUnit = toSelect.value;
|
||||||
|
recalc();
|
||||||
|
saveState();
|
||||||
|
});
|
||||||
|
swapBtn.addEventListener('click', () => {
|
||||||
|
const tmpUnit = _fromUnit;
|
||||||
|
_fromUnit = _toUnit;
|
||||||
|
_toUnit = tmpUnit;
|
||||||
|
populateUnitSelect(fromSelect, _fromUnit);
|
||||||
|
populateUnitSelect(toSelect, _toUnit);
|
||||||
|
const currentVal = _toInput.value;
|
||||||
|
if (currentVal) {
|
||||||
|
_fromInput.value = currentVal;
|
||||||
|
}
|
||||||
|
recalc();
|
||||||
|
saveState();
|
||||||
|
});
|
||||||
|
catSelect.addEventListener('change', () => {
|
||||||
|
_currentCategory = catSelect.value;
|
||||||
|
const defaults = getDefaultUnits(_currentCategory);
|
||||||
|
_fromUnit = defaults.from;
|
||||||
|
_toUnit = defaults.to;
|
||||||
|
populateUnitSelect(fromSelect, _fromUnit);
|
||||||
|
populateUnitSelect(toSelect, _toUnit);
|
||||||
|
_fromInput.value = '';
|
||||||
|
_toInput.value = '';
|
||||||
|
updateReference();
|
||||||
|
saveState();
|
||||||
|
});
|
||||||
|
|
||||||
|
bodyEl.append(catSelect, fromRow, swapBtn, toRow, _refEl);
|
||||||
|
updateReference();
|
||||||
|
}
|
||||||
|
|
||||||
|
Calculator.registerMode('converter', {
|
||||||
|
label: '⚖️',
|
||||||
|
shortName: 'Unit',
|
||||||
|
titleKey: 'calculator.tab.converter',
|
||||||
|
|
||||||
|
render(bodyEl) {
|
||||||
|
bodyEl.style.padding = '8px';
|
||||||
|
bodyEl.style.display = 'flex';
|
||||||
|
bodyEl.style.flexDirection = 'column';
|
||||||
|
bodyEl.style.gap = '8px';
|
||||||
|
|
||||||
|
loadState().then(() => {
|
||||||
|
buildUI(bodyEl);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
_fromInput = null;
|
||||||
|
_toInput = null;
|
||||||
|
_refEl = null;
|
||||||
|
saveState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — calc-factorio.js
|
||||||
|
Factorio Calculator Modus
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const ASSEMBLERS = [
|
||||||
|
{ key: 'asm1', speed: 0.5 },
|
||||||
|
{ key: 'asm2', speed: 0.75 },
|
||||||
|
{ key: 'asm3', speed: 1.25 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const BELTS = [
|
||||||
|
{ key: 'yellow', throughput: 15, perSide: 7.5 },
|
||||||
|
{ key: 'red', throughput: 30, perSide: 15 },
|
||||||
|
{ key: 'blue', throughput: 45, perSide: 22.5 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUB_MODES = ['ratio', 'belt', 'machines'];
|
||||||
|
let _activeSubMode = 'ratio';
|
||||||
|
|
||||||
|
function createAssemblerSelect(selectedKey) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'calc-game-field';
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.textContent = t('calculator.fac.assembler');
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'calc-game-input';
|
||||||
|
ASSEMBLERS.forEach(asm => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = asm.key;
|
||||||
|
opt.textContent = t('calculator.fac.asm.' + asm.key) + ' (' + asm.speed + 'x)';
|
||||||
|
if (asm.key === selectedKey) opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
row.append(label, select);
|
||||||
|
return { row, select };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBeltSelect(selectedKey) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'calc-game-field';
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.textContent = t('calculator.fac.belt');
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'calc-game-input';
|
||||||
|
BELTS.forEach(belt => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = belt.key;
|
||||||
|
opt.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + belt.throughput + '/s)';
|
||||||
|
if (belt.key === selectedKey) opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
row.append(label, select);
|
||||||
|
return { row, select };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssemblerSpeed(key) {
|
||||||
|
const asm = ASSEMBLERS.find(a => a.key === key);
|
||||||
|
return asm ? asm.speed : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBelt(key) {
|
||||||
|
return BELTS.find(b => b.key === key) || BELTS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSmallestBelt(throughput) {
|
||||||
|
for (const belt of BELTS) {
|
||||||
|
if (belt.throughput >= throughput) return belt;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createField(labelKey, defaultVal, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'calc-game-field';
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.textContent = t(labelKey);
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.className = 'calc-game-input';
|
||||||
|
input.value = defaultVal;
|
||||||
|
if (opts.step) input.step = opts.step;
|
||||||
|
if (opts.min !== undefined) input.min = opts.min;
|
||||||
|
row.append(label, input);
|
||||||
|
return { row, input };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOutput(labelKey) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'calc-game-output';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = t(labelKey);
|
||||||
|
const value = document.createElement('span');
|
||||||
|
value.className = 'calc-game-value';
|
||||||
|
row.append(label, value);
|
||||||
|
return { row, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRatio(container) {
|
||||||
|
const asmSelect = createAssemblerSelect('asm3');
|
||||||
|
const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
|
||||||
|
const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });
|
||||||
|
const perSecOutput = createOutput('calculator.fac.items_per_sec');
|
||||||
|
const perMinOutput = createOutput('calculator.fac.items_per_min');
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
const speed = getAssemblerSpeed(asmSelect.select.value);
|
||||||
|
const output = parseFloat(outputField.input.value) || 0;
|
||||||
|
const time = parseFloat(timeField.input.value) || 1;
|
||||||
|
const perSec = output * speed / time;
|
||||||
|
const perMin = perSec * 60;
|
||||||
|
perSecOutput.value.textContent = Calculator._formatResult(perSec) + ' /s';
|
||||||
|
perMinOutput.value.textContent = Calculator._formatResult(perMin) + ' /min';
|
||||||
|
}
|
||||||
|
|
||||||
|
[outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
|
||||||
|
asmSelect.select.addEventListener('change', calc);
|
||||||
|
container.append(asmSelect.row, outputField.row, timeField.row, perSecOutput.row, perMinOutput.row);
|
||||||
|
calc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBelt(container) {
|
||||||
|
const beltSelect = createBeltSelect('yellow');
|
||||||
|
const consumeField = createField('calculator.fac.consume_per_sec', 1, { step: 0.1, min: 0.1 });
|
||||||
|
const machinesOutput = createOutput('calculator.fac.machines_per_belt');
|
||||||
|
const utilOutput = createOutput('calculator.fac.belt_utilization');
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
const belt = getBelt(beltSelect.select.value);
|
||||||
|
const consume = parseFloat(consumeField.input.value) || 1;
|
||||||
|
const machines = Math.floor(belt.throughput / consume);
|
||||||
|
const util = (consume * machines) / belt.throughput * 100;
|
||||||
|
machinesOutput.value.textContent = machines;
|
||||||
|
utilOutput.value.textContent = Calculator._formatResult(util) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeField.input.addEventListener('input', calc);
|
||||||
|
beltSelect.select.addEventListener('change', calc);
|
||||||
|
container.append(beltSelect.row, consumeField.row, machinesOutput.row, utilOutput.row);
|
||||||
|
calc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMachines(container) {
|
||||||
|
const asmSelect = createAssemblerSelect('asm3');
|
||||||
|
const targetField = createField('calculator.fac.target_output_sec', 10, { step: 0.1, min: 0.1 });
|
||||||
|
const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
|
||||||
|
const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });
|
||||||
|
const machinesOutput = createOutput('calculator.fac.machines_needed');
|
||||||
|
const beltOutput = createOutput('calculator.fac.belt_needed');
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
const speed = getAssemblerSpeed(asmSelect.select.value);
|
||||||
|
const target = parseFloat(targetField.input.value) || 0;
|
||||||
|
const output = parseFloat(outputField.input.value) || 1;
|
||||||
|
const time = parseFloat(timeField.input.value) || 1;
|
||||||
|
const perMachine = output * speed / time;
|
||||||
|
const machines = perMachine > 0 ? Math.ceil(target / perMachine) : 0;
|
||||||
|
const totalThroughput = machines * perMachine;
|
||||||
|
const belt = findSmallestBelt(totalThroughput);
|
||||||
|
|
||||||
|
machinesOutput.value.textContent = machines;
|
||||||
|
if (belt) {
|
||||||
|
const util = (totalThroughput / belt.throughput) * 100;
|
||||||
|
beltOutput.value.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + Calculator._formatResult(util) + '%)';
|
||||||
|
} else {
|
||||||
|
beltOutput.value.textContent = t('calculator.fac.exceeds_belt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[targetField, outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
|
||||||
|
asmSelect.select.addEventListener('change', calc);
|
||||||
|
container.append(asmSelect.row, targetField.row, outputField.row, timeField.row, machinesOutput.row, beltOutput.row);
|
||||||
|
calc();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState() {
|
||||||
|
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||||
|
if (data && data.calculator && data.calculator.factorio) {
|
||||||
|
const s = data.calculator.factorio;
|
||||||
|
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveState() {
|
||||||
|
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||||
|
if (!data.calculator) data.calculator = {};
|
||||||
|
data.calculator.factorio = { lastSubMode: _activeSubMode };
|
||||||
|
await Store.set(Calculator.STORAGE_KEY, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubMode(container) {
|
||||||
|
container.textContent = '';
|
||||||
|
switch (_activeSubMode) {
|
||||||
|
case 'ratio': renderRatio(container); break;
|
||||||
|
case 'belt': renderBelt(container); break;
|
||||||
|
case 'machines': renderMachines(container); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Calculator.registerMode('factorio', {
|
||||||
|
label: '🏭',
|
||||||
|
shortName: 'FAC',
|
||||||
|
titleKey: 'calculator.tab.factorio',
|
||||||
|
|
||||||
|
render(bodyEl) {
|
||||||
|
bodyEl.style.padding = '8px';
|
||||||
|
bodyEl.style.display = 'flex';
|
||||||
|
bodyEl.style.flexDirection = 'column';
|
||||||
|
bodyEl.style.gap = '8px';
|
||||||
|
|
||||||
|
loadState().then(() => {
|
||||||
|
const subContent = document.createElement('div');
|
||||||
|
subContent.className = 'calc-game-content';
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'calc-game-subtabs';
|
||||||
|
|
||||||
|
SUB_MODES.forEach(mode => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
|
||||||
|
btn.textContent = t('calculator.fac.tab.' + mode);
|
||||||
|
btn.dataset.mode = mode;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
_activeSubMode = mode;
|
||||||
|
renderSubMode(subContent);
|
||||||
|
saveState();
|
||||||
|
});
|
||||||
|
bar.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
bodyEl.append(bar, subContent);
|
||||||
|
renderSubMode(subContent);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
saveState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — calc-satisfactory.js
|
||||||
|
Satisfactory Calculator Modus
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const POWER_EXPONENT = 1.321928;
|
||||||
|
const SUB_MODES = ['itemsPerMin', 'power', 'machines'];
|
||||||
|
let _activeSubMode = 'itemsPerMin';
|
||||||
|
|
||||||
|
function createField(labelKey, defaultVal, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'calc-game-field';
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.textContent = t(labelKey);
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.className = 'calc-game-input';
|
||||||
|
input.value = defaultVal;
|
||||||
|
if (opts.step) input.step = opts.step;
|
||||||
|
if (opts.min !== undefined) input.min = opts.min;
|
||||||
|
if (opts.max !== undefined) input.max = opts.max;
|
||||||
|
row.append(label, input);
|
||||||
|
return { row, input };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOutput(labelKey) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'calc-game-output';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = t(labelKey);
|
||||||
|
const value = document.createElement('span');
|
||||||
|
value.className = 'calc-game-value';
|
||||||
|
row.append(label, value);
|
||||||
|
return { row, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItemsPerMin(container) {
|
||||||
|
const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
|
||||||
|
const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
|
||||||
|
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
|
||||||
|
const output = createOutput('calculator.sat.output_per_min');
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
const items = parseFloat(itemsField.input.value) || 0;
|
||||||
|
const time = parseFloat(timeField.input.value) || 1;
|
||||||
|
const clock = parseFloat(clockField.input.value) || 100;
|
||||||
|
const result = (items * 60) / time * (clock / 100);
|
||||||
|
output.value.textContent = Calculator._formatResult(result) + ' items/min';
|
||||||
|
}
|
||||||
|
|
||||||
|
[itemsField, timeField, clockField].forEach(f => f.input.addEventListener('input', calc));
|
||||||
|
container.append(itemsField.row, timeField.row, clockField.row, output.row);
|
||||||
|
calc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPower(container) {
|
||||||
|
const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });
|
||||||
|
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
|
||||||
|
const powerOutput = createOutput('calculator.sat.power_usage');
|
||||||
|
const effOutput = createOutput('calculator.sat.efficiency');
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
const basePower = parseFloat(basePowerField.input.value) || 0;
|
||||||
|
const clock = parseFloat(clockField.input.value) || 100;
|
||||||
|
const ratio = clock / 100;
|
||||||
|
const power = basePower * Math.pow(ratio, POWER_EXPONENT);
|
||||||
|
const effPerItem = Math.pow(ratio, POWER_EXPONENT - 1);
|
||||||
|
|
||||||
|
powerOutput.value.textContent = Calculator._formatResult(power) + ' MW';
|
||||||
|
|
||||||
|
if (clock > 100) {
|
||||||
|
const overhead = (effPerItem - 1) * 100;
|
||||||
|
effOutput.value.textContent = '+' + Calculator._formatResult(overhead) + '% ' + t('calculator.sat.per_item');
|
||||||
|
effOutput.row.style.display = '';
|
||||||
|
} else {
|
||||||
|
effOutput.row.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[basePowerField, clockField].forEach(f => f.input.addEventListener('input', calc));
|
||||||
|
container.append(basePowerField.row, clockField.row, powerOutput.row, effOutput.row);
|
||||||
|
calc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMachines(container) {
|
||||||
|
const targetField = createField('calculator.sat.target_output', 60, { step: 1, min: 1 });
|
||||||
|
const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
|
||||||
|
const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
|
||||||
|
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
|
||||||
|
const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });
|
||||||
|
const machinesOutput = createOutput('calculator.sat.machines_needed');
|
||||||
|
const totalPowerOutput = createOutput('calculator.sat.total_power');
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
const target = parseFloat(targetField.input.value) || 0;
|
||||||
|
const items = parseFloat(itemsField.input.value) || 1;
|
||||||
|
const time = parseFloat(timeField.input.value) || 1;
|
||||||
|
const clock = parseFloat(clockField.input.value) || 100;
|
||||||
|
const basePower = parseFloat(basePowerField.input.value) || 0;
|
||||||
|
const ratio = clock / 100;
|
||||||
|
const itemsPerMin = (items * 60) / time * ratio;
|
||||||
|
const machines = itemsPerMin > 0 ? Math.ceil(target / itemsPerMin) : 0;
|
||||||
|
const totalPower = machines * basePower * Math.pow(ratio, POWER_EXPONENT);
|
||||||
|
machinesOutput.value.textContent = machines;
|
||||||
|
totalPowerOutput.value.textContent = Calculator._formatResult(totalPower) + ' MW';
|
||||||
|
}
|
||||||
|
|
||||||
|
[targetField, itemsField, timeField, clockField, basePowerField].forEach(f => f.input.addEventListener('input', calc));
|
||||||
|
container.append(targetField.row, itemsField.row, timeField.row, clockField.row, basePowerField.row, machinesOutput.row, totalPowerOutput.row);
|
||||||
|
calc();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState() {
|
||||||
|
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||||
|
if (data && data.calculator && data.calculator.satisfactory) {
|
||||||
|
const s = data.calculator.satisfactory;
|
||||||
|
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveState() {
|
||||||
|
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||||
|
if (!data.calculator) data.calculator = {};
|
||||||
|
data.calculator.satisfactory = { lastSubMode: _activeSubMode };
|
||||||
|
await Store.set(Calculator.STORAGE_KEY, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubMode(container) {
|
||||||
|
container.textContent = '';
|
||||||
|
switch (_activeSubMode) {
|
||||||
|
case 'itemsPerMin': renderItemsPerMin(container); break;
|
||||||
|
case 'power': renderPower(container); break;
|
||||||
|
case 'machines': renderMachines(container); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Calculator.registerMode('satisfactory', {
|
||||||
|
label: '⚙️',
|
||||||
|
shortName: 'SAT',
|
||||||
|
titleKey: 'calculator.tab.satisfactory',
|
||||||
|
|
||||||
|
render(bodyEl) {
|
||||||
|
bodyEl.style.padding = '8px';
|
||||||
|
bodyEl.style.display = 'flex';
|
||||||
|
bodyEl.style.flexDirection = 'column';
|
||||||
|
bodyEl.style.gap = '8px';
|
||||||
|
|
||||||
|
loadState().then(() => {
|
||||||
|
const subContent = document.createElement('div');
|
||||||
|
subContent.className = 'calc-game-content';
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'calc-game-subtabs';
|
||||||
|
|
||||||
|
SUB_MODES.forEach(mode => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
|
||||||
|
btn.textContent = t('calculator.sat.tab.' + mode);
|
||||||
|
btn.dataset.mode = mode;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
_activeSubMode = mode;
|
||||||
|
renderSubMode(subContent);
|
||||||
|
saveState();
|
||||||
|
});
|
||||||
|
bar.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
bodyEl.append(bar, subContent);
|
||||||
|
renderSubMode(subContent);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
saveState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — calc-scientific.js
|
||||||
|
Scientific-Modus für Calculator Widget
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const FORMULAS = [
|
||||||
|
{
|
||||||
|
key: 'circle_area',
|
||||||
|
fields: [{ key: 'radius', default: '' }],
|
||||||
|
calc: (vals) => Math.PI * vals.radius * vals.radius
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'circle_circumference',
|
||||||
|
fields: [{ key: 'radius', default: '' }],
|
||||||
|
calc: (vals) => 2 * Math.PI * vals.radius
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'celsius_to_fahrenheit',
|
||||||
|
fields: [{ key: 'temp', default: '' }],
|
||||||
|
calc: (vals) => (vals.temp * 9 / 5) + 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fahrenheit_to_celsius',
|
||||||
|
fields: [{ key: 'temp', default: '' }],
|
||||||
|
calc: (vals) => (vals.temp - 32) * 5 / 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pythagoras',
|
||||||
|
fields: [{ key: 'a', default: '' }, { key: 'b', default: '' }],
|
||||||
|
calc: (vals) => Math.sqrt(vals.a * vals.a + vals.b * vals.b)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'percentage',
|
||||||
|
fields: [{ key: 'value', default: '' }, { key: 'percent', default: '' }],
|
||||||
|
calc: (vals) => vals.value * vals.percent / 100
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let _keyboardExtHandler = null;
|
||||||
|
|
||||||
|
function renderSciButtons(container) {
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'calc-buttons calc-sci-buttons';
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
['√', 'sqrt', 'operator'],
|
||||||
|
['x²', 'square', 'operator'],
|
||||||
|
['xⁿ', 'power', 'operator'],
|
||||||
|
['π', 'pi', 'operator'],
|
||||||
|
['e', 'euler', 'operator'],
|
||||||
|
['±', 'negate', 'operator']
|
||||||
|
];
|
||||||
|
|
||||||
|
buttons.forEach(([label, value, cls]) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'calc-btn' + (cls ? ' ' + cls : '');
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.addEventListener('click', () => handleSciKey(value));
|
||||||
|
grid.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSciKey(key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'sqrt':
|
||||||
|
if (!Calculator._currentExpr && Calculator._lastResult) {
|
||||||
|
Calculator._currentExpr = 'sqrt(' + Calculator._lastResult + ')';
|
||||||
|
Calculator._lastResult = '';
|
||||||
|
Calculator._updateDisplay();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Calculator._currentExpr += 'sqrt(';
|
||||||
|
Calculator._updateDisplay();
|
||||||
|
break;
|
||||||
|
case 'square':
|
||||||
|
if (!Calculator._currentExpr && Calculator._lastResult) {
|
||||||
|
Calculator._currentExpr = Calculator._lastResult;
|
||||||
|
Calculator._lastResult = '';
|
||||||
|
}
|
||||||
|
Calculator._currentExpr += '^2';
|
||||||
|
Calculator._updateDisplay();
|
||||||
|
break;
|
||||||
|
case 'power':
|
||||||
|
Calculator._handleKey('^');
|
||||||
|
break;
|
||||||
|
case 'pi':
|
||||||
|
Calculator._currentExpr += '3.14159265359';
|
||||||
|
Calculator._updateDisplay();
|
||||||
|
break;
|
||||||
|
case 'euler':
|
||||||
|
Calculator._currentExpr += '2.71828182846';
|
||||||
|
Calculator._updateDisplay();
|
||||||
|
break;
|
||||||
|
case 'negate':
|
||||||
|
handleNegate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNegate() {
|
||||||
|
const expr = Calculator._currentExpr;
|
||||||
|
if (!expr && Calculator._lastResult) {
|
||||||
|
const num = parseFloat(Calculator._lastResult);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
Calculator._currentExpr = String(-num);
|
||||||
|
Calculator._lastResult = '';
|
||||||
|
Calculator._updateDisplay();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = expr.match(/(-?\d*\.?\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
const num = parseFloat(match[1]);
|
||||||
|
const negated = String(-num);
|
||||||
|
Calculator._currentExpr = expr.slice(0, expr.length - match[1].length) + negated;
|
||||||
|
Calculator._updateDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFormulaHelper(container) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'calc-formula-helper';
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'calc-formula-label';
|
||||||
|
label.textContent = t('calculator.sci.formulas');
|
||||||
|
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'calc-formula-select';
|
||||||
|
|
||||||
|
const emptyOpt = document.createElement('option');
|
||||||
|
emptyOpt.value = '';
|
||||||
|
emptyOpt.textContent = t('calculator.sci.select_formula');
|
||||||
|
select.appendChild(emptyOpt);
|
||||||
|
|
||||||
|
FORMULAS.forEach((f, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = String(i);
|
||||||
|
opt.textContent = t('calculator.sci.formula.' + f.key);
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputsContainer = document.createElement('div');
|
||||||
|
inputsContainer.className = 'calc-formula-inputs';
|
||||||
|
|
||||||
|
const resultContainer = document.createElement('div');
|
||||||
|
resultContainer.className = 'calc-formula-result';
|
||||||
|
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
while (inputsContainer.firstChild) {
|
||||||
|
inputsContainer.removeChild(inputsContainer.firstChild);
|
||||||
|
}
|
||||||
|
resultContainer.textContent = '';
|
||||||
|
|
||||||
|
const idx = parseInt(select.value, 10);
|
||||||
|
if (isNaN(idx)) return;
|
||||||
|
|
||||||
|
const formula = FORMULAS[idx];
|
||||||
|
renderFormulaInputs(formula, inputsContainer, resultContainer);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.append(label, select, inputsContainer, resultContainer);
|
||||||
|
container.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFormulaInputs(formula, inputsEl, resultEl) {
|
||||||
|
const inputs = {};
|
||||||
|
|
||||||
|
formula.fields.forEach(field => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'calc-formula-row';
|
||||||
|
|
||||||
|
const lbl = document.createElement('label');
|
||||||
|
lbl.textContent = t('calculator.sci.field.' + field.key);
|
||||||
|
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.type = 'number';
|
||||||
|
inp.className = 'calc-formula-input';
|
||||||
|
inp.placeholder = '0';
|
||||||
|
inp.step = 'any';
|
||||||
|
inputs[field.key] = inp;
|
||||||
|
|
||||||
|
inp.addEventListener('input', () => {
|
||||||
|
recalcFormula(formula, inputs, resultEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.append(lbl, inp);
|
||||||
|
inputsEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcFormula(formula, inputs, resultEl) {
|
||||||
|
const vals = {};
|
||||||
|
let allValid = true;
|
||||||
|
|
||||||
|
for (const field of formula.fields) {
|
||||||
|
const v = parseFloat(inputs[field.key].value);
|
||||||
|
if (isNaN(v)) { allValid = false; break; }
|
||||||
|
vals[field.key] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allValid) {
|
||||||
|
resultEl.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = formula.calc(vals);
|
||||||
|
if (result === null || !isFinite(result)) {
|
||||||
|
resultEl.textContent = t('calculator.error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultEl.textContent = '= ' + Calculator._formatResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSciKeyboard(widgetEl) {
|
||||||
|
_keyboardExtHandler = (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
if (e.target.contentEditable === 'true') return;
|
||||||
|
|
||||||
|
if (e.key === 'p') {
|
||||||
|
handleSciKey('pi');
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
} else if (e.key === '^') {
|
||||||
|
handleSciKey('power');
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
widgetEl.addEventListener('keydown', _keyboardExtHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
Calculator.registerMode('scientific', {
|
||||||
|
label: '📐',
|
||||||
|
shortName: 'Sci',
|
||||||
|
titleKey: 'calculator.tab.scientific',
|
||||||
|
|
||||||
|
render(bodyEl) {
|
||||||
|
bodyEl.style.padding = '8px';
|
||||||
|
bodyEl.style.display = 'flex';
|
||||||
|
bodyEl.style.flexDirection = 'column';
|
||||||
|
bodyEl.style.flex = '1';
|
||||||
|
bodyEl.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
const display = document.createElement('div');
|
||||||
|
display.className = 'calc-display';
|
||||||
|
|
||||||
|
const exprEl = document.createElement('div');
|
||||||
|
exprEl.className = 'calc-expression';
|
||||||
|
Calculator._displayExprEl = exprEl;
|
||||||
|
|
||||||
|
const resultEl = document.createElement('div');
|
||||||
|
resultEl.className = 'calc-result';
|
||||||
|
resultEl.textContent = Calculator._lastResult || '0';
|
||||||
|
Calculator._displayResultEl = resultEl;
|
||||||
|
|
||||||
|
display.append(exprEl, resultEl);
|
||||||
|
|
||||||
|
const sciSection = document.createElement('div');
|
||||||
|
renderSciButtons(sciSection);
|
||||||
|
|
||||||
|
const stdButtons = Calculator._createButtons();
|
||||||
|
const historyEl = Calculator._createHistoryPanel();
|
||||||
|
|
||||||
|
const formulaSection = document.createElement('div');
|
||||||
|
renderFormulaHelper(formulaSection);
|
||||||
|
|
||||||
|
bodyEl.append(display, sciSection, stdButtons, historyEl, formulaSection);
|
||||||
|
Calculator._updateDisplay();
|
||||||
|
|
||||||
|
const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
|
||||||
|
if (entry) bindSciKeyboard(entry.el);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (_keyboardExtHandler) {
|
||||||
|
const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
|
||||||
|
if (entry) {
|
||||||
|
entry.el.removeEventListener('keydown', _keyboardExtHandler);
|
||||||
|
}
|
||||||
|
_keyboardExtHandler = null;
|
||||||
|
}
|
||||||
|
Calculator._displayExprEl = null;
|
||||||
|
Calculator._displayResultEl = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — calc-stationeers.js
|
||||||
|
Stationeers Calculator Modus
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const R = 8314.46261815324;
|
||||||
|
const COMBUSTION_ENERGY = 563452;
|
||||||
|
const HEAT_CAP_PURE_FUEL = 61.9;
|
||||||
|
const HEAT_CAP_DELTA = 172.615;
|
||||||
|
const BATTERY_CAPACITY = 50000;
|
||||||
|
|
||||||
|
const HEAT_CAPS = [
|
||||||
|
{ gas: 'O\u2082', cp: 21.1 },
|
||||||
|
{ gas: 'H\u2082', cp: 20.4 },
|
||||||
|
{ gas: 'CO\u2082', cp: 28.2 },
|
||||||
|
{ gas: 'N\u2082', cp: 20.6 },
|
||||||
|
{ gas: 'H\u2082O', cp: 72.0 },
|
||||||
|
{ gas: 'N\u2082O', cp: 23.0 },
|
||||||
|
{ gas: 'Pollutant', cp: 24.8 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const GAS_VARS = ['P', 'V', 'n', 'T'];
|
||||||
|
const SUB_MODES = ['gas', 'furnace', 'solar', 'atmo'];
|
||||||
|
let _activeSubMode = 'gas';
|
||||||
|
|
||||||
|
function createField(labelKey, defaultVal, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'calc-game-field';
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.textContent = t(labelKey);
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.className = 'calc-game-input';
|
||||||
|
input.value = defaultVal;
|
||||||
|
if (opts.step) input.step = opts.step;
|
||||||
|
if (opts.min !== undefined) input.min = opts.min;
|
||||||
|
if (opts.max !== undefined) input.max = opts.max;
|
||||||
|
if (opts.disabled) input.disabled = true;
|
||||||
|
row.append(label, input);
|
||||||
|
return { row, input };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOutput(labelKey) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'calc-game-output';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = t(labelKey);
|
||||||
|
const value = document.createElement('span');
|
||||||
|
value.className = 'calc-game-value';
|
||||||
|
row.append(label, value);
|
||||||
|
return { row, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGas(container) {
|
||||||
|
const solveRow = document.createElement('div');
|
||||||
|
solveRow.className = 'calc-game-field';
|
||||||
|
const solveLabel = document.createElement('label');
|
||||||
|
solveLabel.textContent = t('calculator.sta.solve_for');
|
||||||
|
const solveSelect = document.createElement('select');
|
||||||
|
solveSelect.className = 'calc-game-input';
|
||||||
|
|
||||||
|
GAS_VARS.forEach(v => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = v;
|
||||||
|
opt.textContent = t('calculator.sta.var.' + v);
|
||||||
|
solveSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
solveRow.append(solveLabel, solveSelect);
|
||||||
|
container.appendChild(solveRow);
|
||||||
|
|
||||||
|
const fields = {};
|
||||||
|
const defaults = { P: 101.325, V: 1000, n: 1, T: 293.15 };
|
||||||
|
|
||||||
|
GAS_VARS.forEach(v => {
|
||||||
|
const f = createField(
|
||||||
|
'calculator.sta.var.' + v + '_label',
|
||||||
|
defaults[v],
|
||||||
|
{ step: 'any' }
|
||||||
|
);
|
||||||
|
fields[v] = f;
|
||||||
|
container.appendChild(f.row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tempHelper = document.createElement('div');
|
||||||
|
tempHelper.className = 'calc-game-hint';
|
||||||
|
container.appendChild(tempHelper);
|
||||||
|
|
||||||
|
const resultOutput = createOutput('calculator.sta.result');
|
||||||
|
container.appendChild(resultOutput.row);
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
const solveFor = solveSelect.value;
|
||||||
|
|
||||||
|
GAS_VARS.forEach(v => {
|
||||||
|
fields[v].input.disabled = (v === solveFor);
|
||||||
|
fields[v].input.style.opacity = (v === solveFor) ? '0.5' : '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
const P_kPa = parseFloat(fields.P.input.value) || 0;
|
||||||
|
const P = P_kPa * 1000;
|
||||||
|
const V = parseFloat(fields.V.input.value) || 0;
|
||||||
|
const n = parseFloat(fields.n.input.value) || 0;
|
||||||
|
const T = parseFloat(fields.T.input.value) || 0;
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
let unit = '';
|
||||||
|
|
||||||
|
switch (solveFor) {
|
||||||
|
case 'P':
|
||||||
|
if (V > 0) { result = (n * R * T) / V; result /= 1000; unit = 'kPa'; }
|
||||||
|
break;
|
||||||
|
case 'V':
|
||||||
|
if (P > 0) { result = (n * R * T) / P; unit = 'L'; }
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
if (R * T > 0) { result = (P * V) / (R * T); unit = 'mol'; }
|
||||||
|
break;
|
||||||
|
case 'T':
|
||||||
|
if (n * R > 0) { result = (P * V) / (n * R); unit = 'K'; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result !== null && isFinite(result)) {
|
||||||
|
fields[solveFor].input.value = Calculator._formatResult(result);
|
||||||
|
resultOutput.value.textContent = Calculator._formatResult(result) + ' ' + unit;
|
||||||
|
} else {
|
||||||
|
resultOutput.value.textContent = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempVal = parseFloat(fields.T.input.value) || 0;
|
||||||
|
tempHelper.textContent = Calculator._formatResult(tempVal - 273.15) + ' \u00B0C';
|
||||||
|
}
|
||||||
|
|
||||||
|
GAS_VARS.forEach(v => {
|
||||||
|
fields[v].input.addEventListener('input', calc);
|
||||||
|
});
|
||||||
|
solveSelect.addEventListener('change', calc);
|
||||||
|
calc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFurnace(container) {
|
||||||
|
const fuelField = createField('calculator.sta.fuel_ratio', 0.5, { step: 0.01, min: 0, max: 1 });
|
||||||
|
const tempField = createField('calculator.sta.start_temp', 293.15, { step: 1, min: 0 });
|
||||||
|
const pressField = createField('calculator.sta.start_pressure', 101.325, { step: 0.1, min: 0 });
|
||||||
|
|
||||||
|
const tempOutput = createOutput('calculator.sta.temp_after');
|
||||||
|
const pressOutput = createOutput('calculator.sta.pressure_after');
|
||||||
|
const warningEl = document.createElement('div');
|
||||||
|
warningEl.className = 'calc-game-warning';
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
const fuel = parseFloat(fuelField.input.value) || 0;
|
||||||
|
const T_vor = parseFloat(tempField.input.value) || 293.15;
|
||||||
|
const P_vor = parseFloat(pressField.input.value) || 101.325;
|
||||||
|
|
||||||
|
warningEl.textContent = '';
|
||||||
|
if (fuel < 0.05) {
|
||||||
|
warningEl.textContent = t('calculator.sta.warn_low_fuel');
|
||||||
|
}
|
||||||
|
if (P_vor < 10) {
|
||||||
|
warningEl.textContent += (warningEl.textContent ? ' ' : '') + t('calculator.sta.warn_low_pressure');
|
||||||
|
}
|
||||||
|
|
||||||
|
const specificHeat = HEAT_CAP_PURE_FUEL;
|
||||||
|
const T_nach = (T_vor * specificHeat + fuel * COMBUSTION_ENERGY) / (specificHeat + fuel * HEAT_CAP_DELTA);
|
||||||
|
const P_nach = P_vor * T_nach * (1 + 5.7 * fuel) / T_vor;
|
||||||
|
|
||||||
|
tempOutput.value.textContent = Calculator._formatResult(T_nach) + ' K (' + Calculator._formatResult(T_nach - 273.15) + ' \u00B0C)';
|
||||||
|
pressOutput.value.textContent = Calculator._formatResult(P_nach) + ' kPa';
|
||||||
|
}
|
||||||
|
|
||||||
|
[fuelField, tempField, pressField].forEach(f => f.input.addEventListener('input', calc));
|
||||||
|
|
||||||
|
container.append(fuelField.row, tempField.row, pressField.row, warningEl, tempOutput.row, pressOutput.row);
|
||||||
|
calc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSolar(container) {
|
||||||
|
const panelField = createField('calculator.sta.panels', 12, { step: 1, min: 1 });
|
||||||
|
const wattField = createField('calculator.sta.watts_per_panel', 500, { step: 10, min: 1 });
|
||||||
|
const dayField = createField('calculator.sta.day_length', 600, { step: 1, min: 1 });
|
||||||
|
const nightField = createField('calculator.sta.night_length', 600, { step: 1, min: 1 });
|
||||||
|
const consumeField = createField('calculator.sta.consumption', 2000, { step: 10, min: 0 });
|
||||||
|
|
||||||
|
const genOutput = createOutput('calculator.sta.generation');
|
||||||
|
const surplusOutput = createOutput('calculator.sta.surplus');
|
||||||
|
const nightOutput = createOutput('calculator.sta.night_energy');
|
||||||
|
const battOutput = createOutput('calculator.sta.batteries_needed');
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
const panels = parseFloat(panelField.input.value) || 0;
|
||||||
|
const wpp = parseFloat(wattField.input.value) || 0;
|
||||||
|
const nightLen = parseFloat(nightField.input.value) || 0;
|
||||||
|
const consume = parseFloat(consumeField.input.value) || 0;
|
||||||
|
|
||||||
|
const generation = panels * wpp;
|
||||||
|
const surplus = generation - consume;
|
||||||
|
const nightEnergy = consume * nightLen;
|
||||||
|
const batteries = nightEnergy > 0 ? Math.ceil(nightEnergy / BATTERY_CAPACITY) : 0;
|
||||||
|
|
||||||
|
genOutput.value.textContent = Calculator._formatResult(generation) + ' W';
|
||||||
|
|
||||||
|
surplusOutput.value.textContent = Calculator._formatResult(surplus) + ' W';
|
||||||
|
if (surplus < 0) {
|
||||||
|
surplusOutput.value.style.color = 'var(--danger)';
|
||||||
|
} else {
|
||||||
|
surplusOutput.value.style.color = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
nightOutput.value.textContent = Calculator._formatResult(nightEnergy) + ' Ws';
|
||||||
|
battOutput.value.textContent = batteries;
|
||||||
|
}
|
||||||
|
|
||||||
|
[panelField, wattField, dayField, nightField, consumeField].forEach(f => f.input.addEventListener('input', calc));
|
||||||
|
|
||||||
|
container.append(panelField.row, wattField.row, dayField.row, nightField.row, consumeField.row,
|
||||||
|
genOutput.row, surplusOutput.row, nightOutput.row, battOutput.row);
|
||||||
|
calc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAtmo(container) {
|
||||||
|
const targetField = createField('calculator.sta.target_temp', 293.15, { step: 1 });
|
||||||
|
const gas1Field = createField('calculator.sta.gas1_temp', 200, { step: 1 });
|
||||||
|
const gas2Field = createField('calculator.sta.gas2_temp', 400, { step: 1 });
|
||||||
|
|
||||||
|
const m1Output = createOutput('calculator.sta.mixer_input1');
|
||||||
|
const m2Output = createOutput('calculator.sta.mixer_input2');
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
const T0 = parseFloat(targetField.input.value) || 0;
|
||||||
|
const T1 = parseFloat(gas1Field.input.value) || 0;
|
||||||
|
const T2 = parseFloat(gas2Field.input.value) || 0;
|
||||||
|
|
||||||
|
const denom = Math.abs(T1 - T0) + Math.abs(T2 - T0);
|
||||||
|
if (denom === 0) {
|
||||||
|
m1Output.value.textContent = '50%';
|
||||||
|
m2Output.value.textContent = '50%';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const M1 = Math.abs(T2 - T0) / denom;
|
||||||
|
const M2 = 1 - M1;
|
||||||
|
|
||||||
|
m1Output.value.textContent = Calculator._formatResult(M1 * 100) + '%';
|
||||||
|
m2Output.value.textContent = Calculator._formatResult(M2 * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
[targetField, gas1Field, gas2Field].forEach(f => f.input.addEventListener('input', calc));
|
||||||
|
|
||||||
|
container.append(targetField.row, gas1Field.row, gas2Field.row, m1Output.row, m2Output.row);
|
||||||
|
calc();
|
||||||
|
|
||||||
|
const details = document.createElement('details');
|
||||||
|
details.className = 'calc-game-details';
|
||||||
|
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
summary.textContent = t('calculator.sta.heat_cap_ref');
|
||||||
|
details.appendChild(summary);
|
||||||
|
|
||||||
|
const table = document.createElement('table');
|
||||||
|
table.className = 'calc-game-table';
|
||||||
|
|
||||||
|
const thead = document.createElement('thead');
|
||||||
|
const headerRow = document.createElement('tr');
|
||||||
|
const thGas = document.createElement('th');
|
||||||
|
thGas.textContent = t('calculator.sta.gas');
|
||||||
|
const thCp = document.createElement('th');
|
||||||
|
thCp.textContent = 'Cp (J/mol\u00B7K)';
|
||||||
|
headerRow.append(thGas, thCp);
|
||||||
|
thead.appendChild(headerRow);
|
||||||
|
|
||||||
|
const tbody = document.createElement('tbody');
|
||||||
|
HEAT_CAPS.forEach(entry => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const tdGas = document.createElement('td');
|
||||||
|
tdGas.textContent = entry.gas;
|
||||||
|
const tdCp = document.createElement('td');
|
||||||
|
tdCp.textContent = entry.cp;
|
||||||
|
tr.append(tdGas, tdCp);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
table.append(thead, tbody);
|
||||||
|
details.appendChild(table);
|
||||||
|
container.appendChild(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState() {
|
||||||
|
const data = await Store.get(Calculator.STORAGE_KEY);
|
||||||
|
if (data && data.calculator && data.calculator.stationeers) {
|
||||||
|
const s = data.calculator.stationeers;
|
||||||
|
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveState() {
|
||||||
|
const data = await Store.get(Calculator.STORAGE_KEY) || {};
|
||||||
|
if (!data.calculator) data.calculator = {};
|
||||||
|
data.calculator.stationeers = { lastSubMode: _activeSubMode };
|
||||||
|
await Store.set(Calculator.STORAGE_KEY, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubMode(container) {
|
||||||
|
container.textContent = '';
|
||||||
|
switch (_activeSubMode) {
|
||||||
|
case 'gas': renderGas(container); break;
|
||||||
|
case 'furnace': renderFurnace(container); break;
|
||||||
|
case 'solar': renderSolar(container); break;
|
||||||
|
case 'atmo': renderAtmo(container); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Calculator.registerMode('stationeers', {
|
||||||
|
label: '\uD83D\uDE80',
|
||||||
|
shortName: 'STA',
|
||||||
|
titleKey: 'calculator.tab.stationeers',
|
||||||
|
|
||||||
|
render(bodyEl) {
|
||||||
|
bodyEl.style.padding = '8px';
|
||||||
|
bodyEl.style.display = 'flex';
|
||||||
|
bodyEl.style.flexDirection = 'column';
|
||||||
|
bodyEl.style.gap = '8px';
|
||||||
|
|
||||||
|
loadState().then(() => {
|
||||||
|
const subContent = document.createElement('div');
|
||||||
|
subContent.className = 'calc-game-content';
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'calc-game-subtabs';
|
||||||
|
|
||||||
|
SUB_MODES.forEach(mode => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
|
||||||
|
btn.textContent = t('calculator.sta.tab.' + mode);
|
||||||
|
btn.dataset.mode = mode;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
_activeSubMode = mode;
|
||||||
|
renderSubMode(subContent);
|
||||||
|
saveState();
|
||||||
|
});
|
||||||
|
bar.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
bodyEl.append(bar, subContent);
|
||||||
|
renderSubMode(subContent);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
saveState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
+261
-61
@@ -17,6 +17,22 @@ const Calculator = {
|
|||||||
_displayExprEl: null,
|
_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 ----
|
||||||
@@ -69,7 +87,7 @@ const Calculator = {
|
|||||||
|
|
||||||
const widgetId = WidgetManager.create('calculator', {
|
const widgetId = WidgetManager.create('calculator', {
|
||||||
id: this.WIDGET_ID,
|
id: this.WIDGET_ID,
|
||||||
title: 'Taschenrechner',
|
title: t('calculator.title'),
|
||||||
x: saved.x || 400,
|
x: saved.x || 400,
|
||||||
y: saved.y || 120,
|
y: saved.y || 120,
|
||||||
width: saved.width || 280,
|
width: saved.width || 280,
|
||||||
@@ -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');
|
||||||
@@ -214,7 +359,7 @@ const Calculator = {
|
|||||||
|
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.className = 'calc-history-title';
|
title.className = 'calc-history-title';
|
||||||
title.textContent = 'History';
|
title.textContent = t('calculator.history');
|
||||||
container.appendChild(title);
|
container.appendChild(title);
|
||||||
|
|
||||||
this._renderHistoryItems(container);
|
this._renderHistoryItems(container);
|
||||||
@@ -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;
|
||||||
@@ -345,7 +491,7 @@ const Calculator = {
|
|||||||
|
|
||||||
const result = this._evaluate(this._currentExpr);
|
const result = this._evaluate(this._currentExpr);
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
this._lastResult = 'Fehler';
|
this._lastResult = t('calculator.error');
|
||||||
this._updateDisplay();
|
this._updateDisplay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+123
-30
@@ -9,14 +9,41 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert eine freie Layout-Position (LAYOUT-04). Liefert { x, y } nur bei
|
||||||
|
* endlichen Zahlen, sonst null — dann gridded ensureBoardPositions das Board neu.
|
||||||
|
* Ohne das wuerde ein Import jede vom Nutzer gesetzte Board-Position verwerfen.
|
||||||
|
* @param {*} pos
|
||||||
|
* @returns {{x:number,y:number}|null}
|
||||||
|
*/
|
||||||
|
function safePos(pos) {
|
||||||
|
return pos && Number.isFinite(pos.x) && Number.isFinite(pos.y) ? { x: pos.x, y: pos.y } : null;
|
||||||
|
}
|
||||||
|
|
||||||
// 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: '1.11.1',
|
version: '2.3.0',
|
||||||
exported: new Date().toISOString(),
|
exported: new Date().toISOString(),
|
||||||
boards,
|
boards,
|
||||||
settings,
|
settings,
|
||||||
|
trash,
|
||||||
notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : [],
|
notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : [],
|
||||||
calculator: widgetData && widgetData.calculator ? widgetData.calculator.history || [] : [],
|
calculator: widgetData && widgetData.calculator ? widgetData.calculator.history || [] : [],
|
||||||
timerPresets: widgetData && widgetData.timer ? widgetData.timer.presets || [] : []
|
timerPresets: widgetData && widgetData.timer ? widgetData.timer.presets || [] : []
|
||||||
@@ -37,46 +64,109 @@ function initDataButtons() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(await file.text());
|
const data = JSON.parse(await file.text());
|
||||||
if (!Array.isArray(data.boards)) throw new Error('Ungültiges 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;
|
const board = {
|
||||||
b.bookmarks = b.bookmarks.filter(bm => {
|
id: b.id || uid(),
|
||||||
if (!bm || typeof bm.title !== 'string' || typeof bm.url !== 'string') return false;
|
title: String(b.title).slice(0, 100),
|
||||||
bm.id = bm.id || uid();
|
blurred: !!b.blurred,
|
||||||
bm.desc = bm.desc || '';
|
locked: !!b.locked,
|
||||||
return true;
|
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)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
// Freies Layout (LAYOUT-04): valide Position uebernehmen, sonst gridded ensureBoardPositions neu.
|
||||||
|
const pos = safePos(b.pos);
|
||||||
|
if (pos) board.pos = pos;
|
||||||
|
return board;
|
||||||
});
|
});
|
||||||
return true;
|
if (validBoards.length === 0) throw new Error(t('data.no_boards'));
|
||||||
});
|
|
||||||
if (validBoards.length === 0) throw new Error('Keine gültigen Boards gefunden');
|
|
||||||
const ok = await HellionDialog.confirm(
|
const ok = await HellionDialog.confirm(
|
||||||
`${validBoards.length} Boards importieren? Bestehende Daten bleiben erhalten.`,
|
t('data.import_confirm', { count: validBoards.length }),
|
||||||
{ type: 'info', title: 'JSON Import' }
|
{ type: 'info', title: t('data.import_confirm.title') }
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
boards = [...boards, ...validBoards];
|
boards = [...boards, ...validBoards];
|
||||||
await saveBoards();
|
await saveBoards();
|
||||||
renderBoards();
|
renderBoards();
|
||||||
|
|
||||||
|
// Papierkorb importieren (falls vorhanden) — defensiv validiert.
|
||||||
|
if (Array.isArray(data.trash) && data.trash.length > 0) {
|
||||||
|
const validTrash = data.trash
|
||||||
|
.filter(e => e && e.item && ['bookmark', 'board'].includes(e.type) && typeof e.deletedAt === 'number' && Number.isFinite(e.deletedAt))
|
||||||
|
.map(e => ({
|
||||||
|
type: e.type,
|
||||||
|
originBoardId: typeof e.originBoardId === 'string' ? e.originBoardId : null,
|
||||||
|
deletedAt: e.deletedAt,
|
||||||
|
item: e.type === 'board'
|
||||||
|
? {
|
||||||
|
id: e.item.id || uid(),
|
||||||
|
title: String(e.item.title || '').slice(0, 100),
|
||||||
|
blurred: !!e.item.blurred,
|
||||||
|
locked: !!e.item.locked,
|
||||||
|
// Position erhalten, damit ein wiederhergestelltes Board an seinem Platz landet.
|
||||||
|
...(safePos(e.item.pos) ? { pos: safePos(e.item.pos) } : {}),
|
||||||
|
bookmarks: Array.isArray(e.item.bookmarks)
|
||||||
|
? e.item.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) }))
|
||||||
|
.slice(0, 500)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
: (isSafeUrl(e.item.url)
|
||||||
|
? { id: e.item.id || uid(), title: String(e.item.title || '').slice(0, 200), url: e.item.url, desc: String(e.item.desc || '').slice(0, 500) }
|
||||||
|
: null)
|
||||||
|
}))
|
||||||
|
.filter(e => e.item !== null);
|
||||||
|
if (validTrash.length > 0) {
|
||||||
|
// Lokale Eintraege sind die EINZIGE Kopie ihrer geloeschten Daten -> Vorrang. Importierte
|
||||||
|
// stammen aus einem Backup, das der Nutzer noch besitzt -> nachrangig. Daher: erst ALLE
|
||||||
|
// lokalen behalten (pushToTrash kappt sie bereits auf TRASH_MAX_ENTRIES), dann mit den
|
||||||
|
// NEUESTEN importierten bis zur Obergrenze auffuellen. Ein frischer Import verdraengt so
|
||||||
|
// keine aelteren lokalen Sole-Copies mehr (frueheres sort+slice(-N) konnte das, data-loss).
|
||||||
|
const room = Math.max(0, TRASH_MAX_ENTRIES - trash.length);
|
||||||
|
const keptImports = validTrash
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.deletedAt - a.deletedAt) // neueste Importe zuerst
|
||||||
|
.slice(0, room);
|
||||||
|
// Am Ende nach deletedAt aufsteigend fuer eine stabile Anzeige-Reihenfolge.
|
||||||
|
trash = [...trash, ...keptImports].sort((a, b) => a.deletedAt - b.deletedAt);
|
||||||
|
await saveTrash();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notes importieren (falls vorhanden)
|
// Notes importieren (falls vorhanden)
|
||||||
let notesImported = 0;
|
let notesImported = 0;
|
||||||
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 +180,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 +193,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,15 +200,20 @@ function initDataButtons() {
|
|||||||
// Gemeinsam speichern
|
// Gemeinsam speichern
|
||||||
await Store.set('widgetStates', existingWidgets);
|
await Store.set('widgetStates', existingWidgets);
|
||||||
|
|
||||||
const noteMsg = notesImported > 0 ? ` + ${notesImported} Note(s)` : '';
|
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
|
||||||
const calcMsg = calcImported ? ' + Calculator-History' : '';
|
if (notesImported > 0) await Notes.init();
|
||||||
const timerMsg = timerImported ? ' + Timer-Presets' : '';
|
if (calcImported) await Calculator.load();
|
||||||
|
if (timerImported) await Timer.load();
|
||||||
|
|
||||||
|
const noteMsg = notesImported > 0 ? t('data.notes_suffix', { count: notesImported }) : '';
|
||||||
|
const calcMsg = calcImported ? t('data.calc_suffix') : '';
|
||||||
|
const timerMsg = timerImported ? t('data.timer_suffix') : '';
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
`${validBoards.length} Board(s)${noteMsg}${calcMsg}${timerMsg} erfolgreich importiert.`,
|
t('data.import_success', { boards: validBoards.length, notes: noteMsg, calc: calcMsg, timer: timerMsg }),
|
||||||
{ type: 'success', title: 'Import erfolgreich' }
|
{ type: 'success', title: t('data.import_success.title') }
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await HellionDialog.alert('Fehler beim Import: ' + err.message, { type: 'danger', title: 'Import fehlgeschlagen' });
|
await HellionDialog.alert(t('data.import_error', { error: err.message }), { type: 'danger', title: t('data.import_error.title') });
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
});
|
});
|
||||||
|
|||||||
+35
-9
@@ -40,23 +40,34 @@ const HellionDialog = {
|
|||||||
*/
|
*/
|
||||||
_show(config) {
|
_show(config) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
|
const prevFocus = document.activeElement;
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'dialog-overlay';
|
overlay.className = 'dialog-overlay';
|
||||||
|
|
||||||
const box = document.createElement('div');
|
const box = document.createElement('div');
|
||||||
box.className = 'dialog-box';
|
box.className = 'dialog-box';
|
||||||
|
box.setAttribute('role', config.isConfirm ? 'alertdialog' : 'dialog');
|
||||||
|
box.setAttribute('aria-modal', 'true');
|
||||||
|
// Eindeutige IDs pro Dialog-Instanz: kurz gestapelte Dialoge (timer.js/
|
||||||
|
// image-ref.js feuern teils ohne await) duerfen sich keine festen IDs
|
||||||
|
// teilen, sonst liest der Screenreader ueber aria-* den falschen Titel.
|
||||||
|
const uid = 'dlg-' + Date.now().toString(36) + '-' + (HellionDialog._seq = (HellionDialog._seq || 0) + 1);
|
||||||
|
box.setAttribute('aria-labelledby', uid + '-title');
|
||||||
|
box.setAttribute('aria-describedby', uid + '-body');
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'dialog-header';
|
header.className = 'dialog-header';
|
||||||
header.appendChild(this._createIcon(config.type));
|
header.appendChild(this._createIcon(config.type));
|
||||||
const titleSpan = document.createElement('span');
|
const titleSpan = document.createElement('span');
|
||||||
|
titleSpan.id = uid + '-title';
|
||||||
titleSpan.textContent = config.title;
|
titleSpan.textContent = config.title;
|
||||||
header.appendChild(titleSpan);
|
header.appendChild(titleSpan);
|
||||||
|
|
||||||
// Body
|
// Body
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'dialog-body';
|
body.className = 'dialog-body';
|
||||||
|
body.id = uid + '-body';
|
||||||
body.textContent = config.message;
|
body.textContent = config.message;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -64,9 +75,12 @@ const HellionDialog = {
|
|||||||
actions.className = 'dialog-actions';
|
actions.className = 'dialog-actions';
|
||||||
|
|
||||||
function cleanup(result) {
|
function cleanup(result) {
|
||||||
overlay.classList.remove('active');
|
|
||||||
document.removeEventListener('keydown', keyHandler);
|
document.removeEventListener('keydown', keyHandler);
|
||||||
setTimeout(() => overlay.remove(), 200);
|
withViewTransition(() => {
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
overlay.remove();
|
||||||
|
});
|
||||||
|
if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus();
|
||||||
resolve(result);
|
resolve(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,12 +118,24 @@ const HellionDialog = {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cleanup(config.isConfirm ? false : undefined);
|
cleanup(config.isConfirm ? false : undefined);
|
||||||
}
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
// Fokus-Falle: nur die Buttons im actions-Container sind fokussierbar
|
||||||
|
const items = Array.from(actions.querySelectorAll('button'));
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault(); last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault(); first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', keyHandler);
|
document.addEventListener('keydown', keyHandler);
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
// Nächster Frame für CSS-Transition
|
// View-Transition uebernimmt das Fade; Fokus bleibt erhalten
|
||||||
requestAnimationFrame(() => {
|
withViewTransition(() => {
|
||||||
overlay.classList.add('active');
|
overlay.classList.add('active');
|
||||||
confirmBtn.focus();
|
confirmBtn.focus();
|
||||||
});
|
});
|
||||||
@@ -126,8 +152,8 @@ const HellionDialog = {
|
|||||||
const opts = options || {};
|
const opts = options || {};
|
||||||
return this._show({
|
return this._show({
|
||||||
message,
|
message,
|
||||||
title: opts.title || 'Hinweis',
|
title: opts.title || t('dialog.default_title'),
|
||||||
confirmText: opts.confirmText || 'OK',
|
confirmText: opts.confirmText || t('dialog.ok'),
|
||||||
cancelText: '',
|
cancelText: '',
|
||||||
type: opts.type || 'info',
|
type: opts.type || 'info',
|
||||||
isConfirm: false
|
isConfirm: false
|
||||||
@@ -144,9 +170,9 @@ const HellionDialog = {
|
|||||||
const opts = options || {};
|
const opts = options || {};
|
||||||
return this._show({
|
return this._show({
|
||||||
message,
|
message,
|
||||||
title: opts.title || 'Bestätigung',
|
title: opts.title || t('dialog.confirm_title'),
|
||||||
confirmText: opts.confirmText || 'OK',
|
confirmText: opts.confirmText || t('dialog.ok'),
|
||||||
cancelText: opts.cancelText || 'Abbrechen',
|
cancelText: opts.cancelText || t('dialog.cancel'),
|
||||||
type: opts.type || 'info',
|
type: opts.type || 'info',
|
||||||
isConfirm: true
|
isConfirm: true
|
||||||
});
|
});
|
||||||
|
|||||||
+72
-73
@@ -5,95 +5,92 @@
|
|||||||
Bookmarks: Reihenfolge innerhalb eines Boards
|
Bookmarks: Reihenfolge innerhalb eines Boards
|
||||||
============================================= */
|
============================================= */
|
||||||
|
|
||||||
// ---- BOARD DRAG (Pointer Events) ----
|
// ---- BOARD FREE-MOVE (Pointer Events) ----
|
||||||
|
// Neugebaut fuer v2.3 (frueher Reorder mit Ghost/Placeholder). Vorbild:
|
||||||
|
// widgets.js _initDrag — setPointerCapture, offX/offY, onMove mit Clamping
|
||||||
|
// gegen window.innerWidth/Height, onUp schreibt board.pos + saveBoards().
|
||||||
|
// Gebunden am .board-drag-handle, NICHT am ganzen .board, damit Bookmark-Drag,
|
||||||
|
// Klick-Delegation und Action-Buttons frei bleiben.
|
||||||
function initBoardDragDrop() {
|
function initBoardDragDrop() {
|
||||||
const wrapper = document.getElementById('boardsWrapper');
|
const wrapper = document.getElementById('boardsWrapper');
|
||||||
let dragging = null;
|
|
||||||
let placeholder = null;
|
|
||||||
|
|
||||||
function getInsertTarget(clientX, clientY) {
|
|
||||||
const boardEls = Array.from(wrapper.querySelectorAll('.board:not(.dragging)'));
|
|
||||||
for (const b of boardEls) {
|
|
||||||
const r = b.getBoundingClientRect();
|
|
||||||
if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) {
|
|
||||||
return { el: b, before: clientX < r.left + r.width / 2 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
wrapper.querySelectorAll('.board').forEach(boardEl => {
|
wrapper.querySelectorAll('.board').forEach(boardEl => {
|
||||||
const handle = boardEl.querySelector('.board-drag-handle');
|
const handle = boardEl.querySelector('.board-drag-handle');
|
||||||
if (!handle) return;
|
if (!handle) return;
|
||||||
|
|
||||||
handle.style.cursor = 'grab';
|
handle.addEventListener('pointerdown', function onDown(e) {
|
||||||
|
// Auf Mobil ist .board position:static (Stapel) -> kein Free-Move.
|
||||||
handle.addEventListener('pointerdown', e => {
|
if (getComputedStyle(boardEl).position !== 'absolute') return;
|
||||||
|
// Gesperrtes Board (Position fixiert, LAYOUT-LOCK) nicht verschieben. Der Drag-Handle ist
|
||||||
|
// bei .locked schon per CSS ausgeblendet; dieser Guard ist die zweite Sicherung.
|
||||||
|
if (boardEl.classList.contains('locked')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handle.setPointerCapture(e.pointerId);
|
handle.setPointerCapture(e.pointerId);
|
||||||
handle.style.cursor = 'grabbing';
|
// .board.dragging hebt das Board per CSS nach vorne (z-index) UND ist das Signal fuer den
|
||||||
|
// Live-Sync-Guard in app.js (bindStorageSync verwirft ein onChanged-Re-Render, das diesen
|
||||||
const rect = boardEl.getBoundingClientRect();
|
// Drag sonst abreissen wuerde). Der Guard prueft genau diese Klasse (Phase-4-Review 2a).
|
||||||
|
|
||||||
// Ghost
|
|
||||||
const ghost = boardEl.cloneNode(true);
|
|
||||||
ghost.className += ' drag-ghost';
|
|
||||||
ghost.style.left = rect.left + 'px';
|
|
||||||
ghost.style.top = rect.top + 'px';
|
|
||||||
ghost.style.width = rect.width + 'px';
|
|
||||||
ghost.style.height = rect.height + 'px';
|
|
||||||
document.body.appendChild(ghost);
|
|
||||||
|
|
||||||
// Placeholder
|
|
||||||
placeholder = document.createElement('div');
|
|
||||||
placeholder.className = 'board-placeholder';
|
|
||||||
placeholder.style.cssText = `width:${rect.width}px; height:${rect.height}px;`;
|
|
||||||
boardEl.parentNode.insertBefore(placeholder, boardEl);
|
|
||||||
boardEl.classList.add('dragging');
|
boardEl.classList.add('dragging');
|
||||||
|
|
||||||
dragging = { el: boardEl, ghost,
|
const rect = boardEl.getBoundingClientRect();
|
||||||
offsetX: e.clientX - rect.left,
|
const offX = e.clientX - rect.left;
|
||||||
offsetY: e.clientY - rect.top
|
const offY = e.clientY - rect.top;
|
||||||
};
|
const startCX = e.clientX, startCY = e.clientY;
|
||||||
});
|
// Erst eine echte Bewegung (> 3px) zaehlt als Drag. Ein reiner Klick/Tap auf den Handle darf
|
||||||
|
// board.pos NICHT ueberschreiben: renderBoards() schreibt in --board-x/y den gegen die Viewport
|
||||||
|
// GECLAMPTEN Wert, board.pos bleibt absichtlich der wahre (evtl. off-screen) Wert. onUp liest
|
||||||
|
// --board-x/y zurueck — bei einem No-Move-Klick waere das der Clamp und wuerde die wahre
|
||||||
|
// Position zerstoeren (Phase-5-Review, HIGH/data-loss).
|
||||||
|
let moved = false;
|
||||||
|
|
||||||
handle.addEventListener('pointermove', e => {
|
function onMove(ev) {
|
||||||
if (!dragging || dragging.el !== boardEl) return;
|
if (Math.abs(ev.clientX - startCX) > 3 || Math.abs(ev.clientY - startCY) > 3) moved = true;
|
||||||
e.preventDefault();
|
const maxX = window.innerWidth - boardEl.offsetWidth;
|
||||||
dragging.ghost.style.left = (e.clientX - dragging.offsetX) + 'px';
|
const maxY = window.innerHeight - boardEl.offsetHeight;
|
||||||
dragging.ghost.style.top = (e.clientY - dragging.offsetY) + 'px';
|
const x = Math.max(0, Math.min(maxX, ev.clientX - offX));
|
||||||
|
const y = Math.max(48, Math.min(maxY, ev.clientY - offY)); // 48px = Header-Hoehe
|
||||||
const target = getInsertTarget(e.clientX, e.clientY);
|
boardEl.style.setProperty('--board-x', x + 'px');
|
||||||
if (target && target.el !== boardEl) {
|
boardEl.style.setProperty('--board-y', y + 'px');
|
||||||
target.before
|
|
||||||
? target.el.parentNode.insertBefore(placeholder, target.el)
|
|
||||||
: target.el.parentNode.insertBefore(placeholder, target.el.nextSibling);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
handle.addEventListener('pointerup', async () => {
|
// Gemeinsames Aufraeumen: Pointer-Capture freigeben, ALLE Listener entfernen,
|
||||||
if (!dragging || dragging.el !== boardEl) return;
|
// .board.dragging entfernen. MUSS auch im Cancel-Pfad laufen — sonst klebt die Klasse
|
||||||
handle.style.cursor = 'grab';
|
// und der app.js-Sync-Guard unterdrueckt dauerhaft Quick-Save-Renders (Phase-5-Review).
|
||||||
placeholder.parentNode.insertBefore(boardEl, placeholder);
|
function cleanup() {
|
||||||
placeholder.remove(); placeholder = null;
|
try { handle.releasePointerCapture(e.pointerId); } catch (_) { /* schon freigegeben */ }
|
||||||
boardEl.classList.remove('dragging');
|
handle.removeEventListener('pointermove', onMove);
|
||||||
dragging.ghost.remove();
|
handle.removeEventListener('pointerup', onUp);
|
||||||
dragging = null;
|
handle.removeEventListener('pointercancel', onCancel);
|
||||||
|
boardEl.classList.remove('dragging');
|
||||||
|
}
|
||||||
|
|
||||||
// Neue Reihenfolge aus DOM ablesen
|
async function onUp() {
|
||||||
const newOrder = Array.from(wrapper.querySelectorAll('.board'))
|
cleanup();
|
||||||
.map(el => el.dataset.boardId).filter(Boolean);
|
// Nur bei echtem Verschieben persistieren — sonst board.pos unangetastet lassen.
|
||||||
boards.sort((a, b) => newOrder.indexOf(a.id) - newOrder.indexOf(b.id));
|
if (moved) {
|
||||||
await saveBoards();
|
const id = boardEl.dataset.boardId;
|
||||||
});
|
const board = boards.find(b => b.id === id);
|
||||||
|
if (board) {
|
||||||
|
board.pos = {
|
||||||
|
x: parseFloat(boardEl.style.getPropertyValue('--board-x')),
|
||||||
|
y: parseFloat(boardEl.style.getPropertyValue('--board-y'))
|
||||||
|
};
|
||||||
|
await saveBoards();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Einen waehrend des Drags ausgelassenen Quick-Save-Render nachholen (app.js).
|
||||||
|
if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
|
||||||
|
}
|
||||||
|
|
||||||
handle.addEventListener('pointercancel', () => {
|
// pointercancel feuert STATT pointerup bei Touch-Interrupt, Browser-Geste oder wenn das
|
||||||
if (!dragging) return;
|
// captured Element aus dem DOM faellt -> nur aufraeumen (cleanup), pos NICHT ueberschreiben.
|
||||||
dragging.ghost.remove();
|
function onCancel() {
|
||||||
if (placeholder) { placeholder.remove(); placeholder = null; }
|
cleanup();
|
||||||
boardEl.classList.remove('dragging');
|
if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
|
||||||
dragging = null;
|
}
|
||||||
handle.style.cursor = 'grab';
|
|
||||||
|
handle.addEventListener('pointermove', onMove);
|
||||||
|
handle.addEventListener('pointerup', onUp);
|
||||||
|
handle.addEventListener('pointercancel', onCancel);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -113,6 +110,8 @@ function initBookmarkDragDrop(listEl, board) {
|
|||||||
listEl.addEventListener('dragend', e => {
|
listEl.addEventListener('dragend', e => {
|
||||||
const item = e.target.closest('.bm-item');
|
const item = e.target.closest('.bm-item');
|
||||||
if (item) item.classList.remove('dragging-source');
|
if (item) item.classList.remove('dragging-source');
|
||||||
|
// Blieb ein Quick-Save-Render waehrend des Bookmark-Drags aus (Drop ausgeblieben), jetzt nachholen.
|
||||||
|
if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
|
||||||
});
|
});
|
||||||
|
|
||||||
listEl.addEventListener('dragover', e => {
|
listEl.addEventListener('dragover', e => {
|
||||||
|
|||||||
+1012
File diff suppressed because it is too large
Load Diff
+38
-47
@@ -114,8 +114,8 @@ const ImageRef = {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e);
|
console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e);
|
||||||
HellionDialog.alert(
|
HellionDialog.alert(
|
||||||
'Bild konnte nicht gespeichert werden. Der Browser-Speicher ist voll.',
|
t('imageref.storage_error'),
|
||||||
{ type: 'danger', title: 'Speicherfehler' }
|
{ type: 'danger', title: t('imageref.storage_error.title') }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -144,8 +144,8 @@ const ImageRef = {
|
|||||||
|
|
||||||
if (this._images.length >= this.MAX_IMAGES) {
|
if (this._images.length >= this.MAX_IMAGES) {
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
'Maximal ' + this.MAX_IMAGES + ' Bild-Widgets gleichzeitig. Schliesse eines um ein neues zu oeffnen.',
|
t('imageref.limit', { max: this.MAX_IMAGES }),
|
||||||
{ type: 'warning', title: 'Limit erreicht' }
|
{ type: 'warning', title: t('imageref.limit.title') }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,8 +172,8 @@ const ImageRef = {
|
|||||||
dataUrl = await this._processFile(file);
|
dataUrl = await this._processFile(file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
'Bild konnte nicht geladen werden: ' + err.message,
|
t('imageref.load_error', { error: err.message }),
|
||||||
{ type: 'danger', title: 'Bildfehler' }
|
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ const ImageRef = {
|
|||||||
_createWidget(imageData, dataUrl) {
|
_createWidget(imageData, dataUrl) {
|
||||||
WidgetManager.create('image', {
|
WidgetManager.create('image', {
|
||||||
id: imageData.id,
|
id: imageData.id,
|
||||||
title: imageData.label || 'Bild-Referenz',
|
title: imageData.label || t('imageref.title'),
|
||||||
x: imageData.x,
|
x: imageData.x,
|
||||||
y: imageData.y,
|
y: imageData.y,
|
||||||
width: imageData.width,
|
width: imageData.width,
|
||||||
@@ -249,14 +249,14 @@ const ImageRef = {
|
|||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.className = 'imgref-img';
|
img.className = 'imgref-img';
|
||||||
img.src = dataUrl;
|
img.src = dataUrl;
|
||||||
img.alt = imageData.label || 'Bild-Referenz';
|
img.alt = imageData.label || t('imageref.title');
|
||||||
wrapper.appendChild(img);
|
wrapper.appendChild(img);
|
||||||
|
|
||||||
// Bild ersetzen Button
|
// Bild ersetzen Button
|
||||||
const replaceBtn = document.createElement('button');
|
const replaceBtn = document.createElement('button');
|
||||||
replaceBtn.className = 'imgref-replace-btn';
|
replaceBtn.className = 'imgref-replace-btn';
|
||||||
replaceBtn.type = 'button';
|
replaceBtn.type = 'button';
|
||||||
replaceBtn.textContent = 'Bild ersetzen';
|
replaceBtn.textContent = t('imageref.replace');
|
||||||
replaceBtn.addEventListener('click', async () => {
|
replaceBtn.addEventListener('click', async () => {
|
||||||
const file = await this._pickFile();
|
const file = await this._pickFile();
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -266,8 +266,8 @@ const ImageRef = {
|
|||||||
this.renderBody(imageData, bodyEl, newDataUrl);
|
this.renderBody(imageData, bodyEl, newDataUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
'Bild konnte nicht geladen werden: ' + err.message,
|
t('imageref.load_error', { error: err.message }),
|
||||||
{ type: 'danger', title: 'Bildfehler' }
|
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -283,7 +283,7 @@ const ImageRef = {
|
|||||||
const label = document.createElement('input');
|
const label = document.createElement('input');
|
||||||
label.className = 'imgref-label';
|
label.className = 'imgref-label';
|
||||||
label.type = 'text';
|
label.type = 'text';
|
||||||
label.placeholder = 'Beschriftung (optional)';
|
label.placeholder = t('imageref.label_placeholder');
|
||||||
label.maxLength = 100;
|
label.maxLength = 100;
|
||||||
label.value = imageData.label || '';
|
label.value = imageData.label || '';
|
||||||
|
|
||||||
@@ -294,9 +294,9 @@ 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 || 'Bild-Referenz';
|
if (titleEl) titleEl.textContent = text || t('imageref.title');
|
||||||
entry.state.title = text || 'Bild-Referenz';
|
entry.state.title = text || t('imageref.title');
|
||||||
}
|
}
|
||||||
|
|
||||||
this._debouncedSave();
|
this._debouncedSave();
|
||||||
@@ -321,7 +321,7 @@ const ImageRef = {
|
|||||||
icon.textContent = '\uD83D\uDDBC\uFE0F';
|
icon.textContent = '\uD83D\uDDBC\uFE0F';
|
||||||
|
|
||||||
const text = document.createElement('span');
|
const text = document.createElement('span');
|
||||||
text.textContent = 'Klicken oder Bild hierher ziehen';
|
text.textContent = t('imageref.dropzone');
|
||||||
|
|
||||||
dropzone.append(icon, text);
|
dropzone.append(icon, text);
|
||||||
|
|
||||||
@@ -336,8 +336,8 @@ const ImageRef = {
|
|||||||
await this.save();
|
await this.save();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
'Bild konnte nicht geladen werden: ' + err.message,
|
t('imageref.load_error', { error: err.message }),
|
||||||
{ type: 'danger', title: 'Bildfehler' }
|
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -363,8 +363,8 @@ const ImageRef = {
|
|||||||
const file = e.dataTransfer.files[0];
|
const file = e.dataTransfer.files[0];
|
||||||
if (!file || !file.type.startsWith('image/')) {
|
if (!file || !file.type.startsWith('image/')) {
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
'Bitte eine Bilddatei verwenden (PNG, JPG, WebP, etc.).',
|
t('imageref.invalid_file'),
|
||||||
{ type: 'warning', title: 'Kein Bild' }
|
{ type: 'warning', title: t('imageref.invalid_file.title') }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -376,8 +376,8 @@ const ImageRef = {
|
|||||||
await this.save();
|
await this.save();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
'Bild konnte nicht geladen werden: ' + err.message,
|
t('imageref.load_error', { error: err.message }),
|
||||||
{ type: 'danger', title: 'Bildfehler' }
|
{ type: 'danger', title: t('imageref.load_error.title') }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -433,7 +433,7 @@ const ImageRef = {
|
|||||||
|
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
reject(new Error('Bild konnte nicht geladen werden'));
|
reject(new Error(t('imageref.load_error', { error: 'unknown' })));
|
||||||
};
|
};
|
||||||
|
|
||||||
img.src = objectUrl;
|
img.src = objectUrl;
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+16
-16
@@ -75,15 +75,15 @@ const Notes = {
|
|||||||
async create(template) {
|
async create(template) {
|
||||||
if (this._notes.length >= this.MAX_NOTES) {
|
if (this._notes.length >= this.MAX_NOTES) {
|
||||||
await HellionDialog.alert(
|
await HellionDialog.alert(
|
||||||
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_NOTES + ' Notes gleichzeitig haben. Lösche eine bestehende Note um eine neue zu erstellen.',
|
t('notes.limit_message', { max: this.MAX_NOTES }),
|
||||||
{ type: 'warning', title: 'Limit erreicht' }
|
{ type: 'warning', title: t('notes.limit_title') }
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noteData = {
|
const noteData = {
|
||||||
id: 'note_' + uid(),
|
id: 'note_' + uid(),
|
||||||
title: template === 'checklist' ? 'Checkliste' : 'Note',
|
title: template === 'checklist' ? t('notes.checklist_title') : t('notes.default_title'),
|
||||||
content: '',
|
content: '',
|
||||||
template: template,
|
template: template,
|
||||||
x: 120 + (this._notes.length * 30),
|
x: 120 + (this._notes.length * 30),
|
||||||
@@ -138,7 +138,7 @@ const Notes = {
|
|||||||
_renderTextBody(noteData, bodyEl) {
|
_renderTextBody(noteData, bodyEl) {
|
||||||
const textarea = document.createElement('textarea');
|
const textarea = document.createElement('textarea');
|
||||||
textarea.className = 'widget-textarea';
|
textarea.className = 'widget-textarea';
|
||||||
textarea.placeholder = 'Notiz schreiben...';
|
textarea.placeholder = t('notes.placeholder');
|
||||||
textarea.spellcheck = false;
|
textarea.spellcheck = false;
|
||||||
textarea.value = noteData.content || '';
|
textarea.value = noteData.content || '';
|
||||||
textarea.maxLength = this.MAX_CHARS;
|
textarea.maxLength = this.MAX_CHARS;
|
||||||
@@ -204,7 +204,7 @@ const Notes = {
|
|||||||
const addInput = document.createElement('input');
|
const addInput = document.createElement('input');
|
||||||
addInput.className = 'checklist-add-input';
|
addInput.className = 'checklist-add-input';
|
||||||
addInput.type = 'text';
|
addInput.type = 'text';
|
||||||
addInput.placeholder = 'Neues Item...';
|
addInput.placeholder = t('notes.checklist_placeholder');
|
||||||
addInput.maxLength = 100;
|
addInput.maxLength = 100;
|
||||||
|
|
||||||
addInput.addEventListener('keydown', async (e) => {
|
addInput.addEventListener('keydown', async (e) => {
|
||||||
@@ -276,11 +276,11 @@ const Notes = {
|
|||||||
// Auto-Titel: "X/Y erledigt" falls kein manueller Titel
|
// Auto-Titel: "X/Y erledigt" falls kein manueller Titel
|
||||||
const widgetEntry = WidgetManager._widgets.get(noteData.id);
|
const widgetEntry = WidgetManager._widgets.get(noteData.id);
|
||||||
if (widgetEntry) {
|
if (widgetEntry) {
|
||||||
const defaultTitle = done + '/' + total + ' erledigt';
|
const defaultTitle = t('notes.checklist_progress', { done: done, total: total });
|
||||||
const titleEl = widgetEntry.el.querySelector('.widget-title');
|
const titleEl = widgetEntry.el.querySelector('.widget-title');
|
||||||
if (titleEl && titleEl.contentEditable !== 'true') {
|
if (titleEl && titleEl.contentEditable !== 'true') {
|
||||||
// Nur wenn Titel noch Standard ist
|
// Nur wenn Titel noch Standard ist
|
||||||
if (noteData.title === 'Checkliste' || /^\d+\/\d+ erledigt$/.test(noteData.title)) {
|
if (noteData.title === t('notes.checklist_title') || /^\d+\/\d+\s/.test(noteData.title)) {
|
||||||
noteData.title = defaultTitle;
|
noteData.title = defaultTitle;
|
||||||
titleEl.textContent = defaultTitle;
|
titleEl.textContent = defaultTitle;
|
||||||
widgetEntry.state.title = defaultTitle;
|
widgetEntry.state.title = defaultTitle;
|
||||||
@@ -307,8 +307,8 @@ const Notes = {
|
|||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
|
|
||||||
const ok = await HellionDialog.confirm(
|
const ok = await HellionDialog.confirm(
|
||||||
'Note endgültig löschen? Das kann nicht rückgängig gemacht werden.',
|
t('notes.delete_confirm'),
|
||||||
{ type: 'danger', title: 'Note löschen', confirmText: 'Löschen' }
|
{ type: 'danger', title: t('notes.delete_title'), confirmText: t('notes.delete_button') }
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ const Notes = {
|
|||||||
} else {
|
} else {
|
||||||
md += noteData.content || '';
|
md += noteData.content || '';
|
||||||
}
|
}
|
||||||
md += '\n\n---\n*Exportiert aus Hellion Dashboard*\n';
|
md += '\n\n---\n*' + t('notes.export_footer') + '*\n';
|
||||||
|
|
||||||
const blob = new Blob([md], { type: 'text/markdown' });
|
const blob = new Blob([md], { type: 'text/markdown' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -419,9 +419,9 @@ const Notes = {
|
|||||||
if (note.template === 'checklist') {
|
if (note.template === 'checklist') {
|
||||||
const total = note.checklistItems ? note.checklistItems.length : 0;
|
const total = note.checklistItems ? note.checklistItems.length : 0;
|
||||||
const done = note.checklistItems ? note.checklistItems.filter(i => i.checked).length : 0;
|
const done = note.checklistItems ? note.checklistItems.filter(i => i.checked).length : 0;
|
||||||
preview.textContent = done + '/' + total + ' erledigt';
|
preview.textContent = t('notes.checklist_progress', { done: done, total: total });
|
||||||
} else {
|
} else {
|
||||||
preview.textContent = (note.content || '').slice(0, 50) || 'Leer';
|
preview.textContent = (note.content || '').slice(0, 50) || t('notes.empty_preview');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -430,7 +430,7 @@ const Notes = {
|
|||||||
|
|
||||||
const btnExport = document.createElement('button');
|
const btnExport = document.createElement('button');
|
||||||
btnExport.className = 'notebook-slot-btn';
|
btnExport.className = 'notebook-slot-btn';
|
||||||
btnExport.textContent = 'Export';
|
btnExport.textContent = t('notes.export');
|
||||||
btnExport.addEventListener('click', (e) => {
|
btnExport.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.exportNote(note);
|
this.exportNote(note);
|
||||||
@@ -470,7 +470,7 @@ const Notes = {
|
|||||||
slot.className = 'notebook-slot-empty';
|
slot.className = 'notebook-slot-empty';
|
||||||
|
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.textContent = '+ Note erstellen';
|
label.textContent = t('notes.create');
|
||||||
slot.appendChild(label);
|
slot.appendChild(label);
|
||||||
|
|
||||||
// Klick zeigt Typ-Auswahl
|
// Klick zeigt Typ-Auswahl
|
||||||
@@ -485,7 +485,7 @@ const Notes = {
|
|||||||
|
|
||||||
const btnText = document.createElement('button');
|
const btnText = document.createElement('button');
|
||||||
btnText.className = 'notebook-type-btn';
|
btnText.className = 'notebook-type-btn';
|
||||||
btnText.textContent = '\u270E Freitext';
|
btnText.textContent = t('notes.text_type');
|
||||||
btnText.addEventListener('click', async (e) => {
|
btnText.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
await this.create('text');
|
await this.create('text');
|
||||||
@@ -494,7 +494,7 @@ const Notes = {
|
|||||||
|
|
||||||
const btnCheck = document.createElement('button');
|
const btnCheck = document.createElement('button');
|
||||||
btnCheck.className = 'notebook-type-btn';
|
btnCheck.className = 'notebook-type-btn';
|
||||||
btnCheck.textContent = '\u2611 Checkliste';
|
btnCheck.textContent = t('notes.checklist_type');
|
||||||
btnCheck.addEventListener('click', async (e) => {
|
btnCheck.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
await this.create('checklist');
|
await this.create('checklist');
|
||||||
|
|||||||
+32
-39
@@ -9,52 +9,45 @@ const Onboarding = {
|
|||||||
slides: [
|
slides: [
|
||||||
{
|
{
|
||||||
hero: '\u2B21',
|
hero: '\u2B21',
|
||||||
title: 'Willkommen bei Hellion Dashboard',
|
titleKey: 'onboarding.s1.title',
|
||||||
text: 'Dein neuer Browser-Startbildschirm. Minimalistisch, schnell und vollst\u00E4ndig lokal \u2014 keine Cloud, kein Account, keine Datensammlung.'
|
textKey: 'onboarding.s1.text'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hero: '\uD83D\uDCCB',
|
hero: '\uD83D\uDCCB',
|
||||||
title: 'Boards & Bookmarks',
|
titleKey: 'onboarding.s2.title',
|
||||||
features: [
|
featureKeys: ['onboarding.s2.f1', 'onboarding.s2.f2', 'onboarding.s2.f3', 'onboarding.s2.f4']
|
||||||
'Erstelle Boards mit dem \u201E+ Board\u201C Button oben',
|
|
||||||
'Importiere Browser-Lesezeichen \u00FCber den \u201EImport\u201C Button im Header',
|
|
||||||
'Drag & Drop zum Umsortieren von Boards und Links',
|
|
||||||
'Blur-Modus f\u00FCr private Boards (\uD83D\uDD12 Icon)'
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hero: '\uD83C\uDFA8',
|
hero: '\uD83C\uDFA8',
|
||||||
title: '11 handgefertigte Themes',
|
titleKey: 'onboarding.s3.title',
|
||||||
text: 'Klicke auf den \u201ETheme\u201C Button im Header um dein Theme zu w\u00E4hlen. Jedes hat seinen eigenen Stil und Farbpalette.',
|
textKey: 'onboarding.s3.text',
|
||||||
showThemes: true
|
showThemes: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hero: '\uD83E\uDDF0',
|
hero: '\uD83E\uDDF0',
|
||||||
title: 'Widget-Toolbar',
|
titleKey: 'onboarding.s4.title',
|
||||||
features: [
|
featureKeys: ['onboarding.s4.f1', 'onboarding.s4.f2', 'onboarding.s4.f3', 'onboarding.s4.f4', 'onboarding.s4.f5', 'onboarding.s4.f6']
|
||||||
'Die schwebenden Buttons rechts \u00F6ffnen Widgets',
|
|
||||||
'Notes und Checklisten f\u00FCr schnelle Notizen',
|
|
||||||
'Taschenrechner mit History',
|
|
||||||
'Timer/Countdown mit speicherbaren Presets',
|
|
||||||
'Bild-Referenz Widgets (aktivierbar in Settings)',
|
|
||||||
'Notebook-Sidebar zeigt alle Notes auf einen Blick'
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hero: '\uD83D\uDEE1\uFE0F',
|
hero: '\uD83D\uDEE1\uFE0F',
|
||||||
title: 'Backups nicht vergessen!',
|
titleKey: 'onboarding.s5.title',
|
||||||
text: 'Deine Daten sind lokal im Browser gespeichert. Wenn du Browserdaten l\u00F6schst, gehen sie verloren! Sichere regelm\u00E4\u00DFig \u00FCber Settings \u2192 Data \u2192 Export. Wir erinnern dich alle 7 Tage daran.'
|
textKey: 'onboarding.s5.text'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hero: '\uD83C\uDFAE',
|
hero: '\uD83C\uDFAE',
|
||||||
title: 'Gaming Starter Board',
|
titleKey: 'onboarding.s6.title',
|
||||||
text: 'Spielst du Games wie Satisfactory, Factorio oder Star Citizen? Ich kann ein Board mit n\u00FCtzlichen Community-Links anlegen.',
|
textKey: 'onboarding.s6.text',
|
||||||
interactive: 'gaming-board'
|
interactive: 'gaming-board'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
hero: '\uD83D\uDD0D',
|
||||||
|
titleKey: 'onboarding.palette.title',
|
||||||
|
textKey: 'onboarding.palette.text'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
hero: '\uD83D\uDE80',
|
hero: '\uD83D\uDE80',
|
||||||
title: 'Bereit!',
|
titleKey: 'onboarding.s7.title',
|
||||||
text: 'Erstelle dein erstes Board mit \u201E+ Board\u201C oder importiere deine Browser-Lesezeichen \u00FCber den Import-Button im Header. Viel Spa\u00DF!'
|
textKey: 'onboarding.s7.text'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -87,7 +80,7 @@ const Onboarding = {
|
|||||||
if (!isLast) {
|
if (!isLast) {
|
||||||
const skip = document.createElement('button');
|
const skip = document.createElement('button');
|
||||||
skip.className = 'onboarding-skip';
|
skip.className = 'onboarding-skip';
|
||||||
skip.textContent = '\u00DCberspringen';
|
skip.textContent = t('onboarding.skip');
|
||||||
skip.addEventListener('click', () => this._finish());
|
skip.addEventListener('click', () => this._finish());
|
||||||
modal.appendChild(skip);
|
modal.appendChild(skip);
|
||||||
}
|
}
|
||||||
@@ -103,22 +96,22 @@ const Onboarding = {
|
|||||||
|
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.className = 'onboarding-title';
|
title.className = 'onboarding-title';
|
||||||
title.textContent = slide.title;
|
title.textContent = t(slide.titleKey);
|
||||||
slideEl.appendChild(title);
|
slideEl.appendChild(title);
|
||||||
|
|
||||||
if (slide.text) {
|
if (slide.textKey) {
|
||||||
const text = document.createElement('div');
|
const text = document.createElement('div');
|
||||||
text.className = 'onboarding-text';
|
text.className = 'onboarding-text';
|
||||||
text.textContent = slide.text;
|
text.textContent = t(slide.textKey);
|
||||||
slideEl.appendChild(text);
|
slideEl.appendChild(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slide.features) {
|
if (slide.featureKeys) {
|
||||||
const list = document.createElement('ul');
|
const list = document.createElement('ul');
|
||||||
list.className = 'onboarding-feature-list';
|
list.className = 'onboarding-feature-list';
|
||||||
slide.features.forEach(f => {
|
slide.featureKeys.forEach(key => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.textContent = f;
|
li.textContent = t(key);
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
slideEl.appendChild(list);
|
slideEl.appendChild(list);
|
||||||
@@ -160,7 +153,7 @@ const Onboarding = {
|
|||||||
if (this.currentSlide > 0) {
|
if (this.currentSlide > 0) {
|
||||||
const backBtn = document.createElement('button');
|
const backBtn = document.createElement('button');
|
||||||
backBtn.className = 'btn-secondary';
|
backBtn.className = 'btn-secondary';
|
||||||
backBtn.textContent = 'Zur\u00FCck';
|
backBtn.textContent = t('onboarding.back');
|
||||||
backBtn.addEventListener('click', () => {
|
backBtn.addEventListener('click', () => {
|
||||||
this.currentSlide--;
|
this.currentSlide--;
|
||||||
this._render();
|
this._render();
|
||||||
@@ -172,7 +165,7 @@ const Onboarding = {
|
|||||||
// Interaktive Slide: Zwei Buttons statt "Weiter"
|
// Interaktive Slide: Zwei Buttons statt "Weiter"
|
||||||
const noBtn = document.createElement('button');
|
const noBtn = document.createElement('button');
|
||||||
noBtn.className = 'btn-secondary';
|
noBtn.className = 'btn-secondary';
|
||||||
noBtn.textContent = 'Nein danke';
|
noBtn.textContent = t('onboarding.no');
|
||||||
noBtn.addEventListener('click', () => {
|
noBtn.addEventListener('click', () => {
|
||||||
this.currentSlide++;
|
this.currentSlide++;
|
||||||
this._render();
|
this._render();
|
||||||
@@ -180,7 +173,7 @@ const Onboarding = {
|
|||||||
|
|
||||||
const yesBtn = document.createElement('button');
|
const yesBtn = document.createElement('button');
|
||||||
yesBtn.className = 'btn-primary';
|
yesBtn.className = 'btn-primary';
|
||||||
yesBtn.textContent = 'Ja, gerne';
|
yesBtn.textContent = t('onboarding.yes');
|
||||||
yesBtn.addEventListener('click', async () => {
|
yesBtn.addEventListener('click', async () => {
|
||||||
await this._createGamingBoard();
|
await this._createGamingBoard();
|
||||||
this.currentSlide++;
|
this.currentSlide++;
|
||||||
@@ -191,13 +184,13 @@ const Onboarding = {
|
|||||||
} else if (isLast) {
|
} else if (isLast) {
|
||||||
const startBtn = document.createElement('button');
|
const startBtn = document.createElement('button');
|
||||||
startBtn.className = 'btn-primary';
|
startBtn.className = 'btn-primary';
|
||||||
startBtn.textContent = 'Los geht\u2019s!';
|
startBtn.textContent = t('onboarding.start');
|
||||||
startBtn.addEventListener('click', () => this._finish());
|
startBtn.addEventListener('click', () => this._finish());
|
||||||
nav.appendChild(startBtn);
|
nav.appendChild(startBtn);
|
||||||
} else {
|
} else {
|
||||||
const nextBtn = document.createElement('button');
|
const nextBtn = document.createElement('button');
|
||||||
nextBtn.className = 'btn-primary';
|
nextBtn.className = 'btn-primary';
|
||||||
nextBtn.textContent = 'Weiter';
|
nextBtn.textContent = t('onboarding.next');
|
||||||
nextBtn.addEventListener('click', () => {
|
nextBtn.addEventListener('click', () => {
|
||||||
this.currentSlide++;
|
this.currentSlide++;
|
||||||
this._render();
|
this._render();
|
||||||
@@ -227,7 +220,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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,3 +32,59 @@ chrome.tabs.onActivated.addListener((activeInfo) => {
|
|||||||
if (tab) forceRedirect(tab.id, tab.url);
|
if (tab) forceRedirect(tab.id, tab.url);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- QUICK SAVE (v2.3, additiv — Redirect oben unberuehrt) ----
|
||||||
|
// Geteiltes Helfer-Modul: ensureInbox(boards), uid(), normalizeBookmark(...).
|
||||||
|
// Pfad ist relativ zu DIESER Datei (src/js/opera/), daher ../quicksave-core.js.
|
||||||
|
importScripts('../quicksave-core.js');
|
||||||
|
|
||||||
|
// Interne/nicht speicherbare Seiten (Browser-UI, Extension-Seiten) — kein sinnvolles Bookmark.
|
||||||
|
const UNSAVEABLE_URL = /^(chrome|chrome-extension|about|edge|opera|moz-extension|brave|vivaldi|view-source|devtools):/i;
|
||||||
|
|
||||||
|
// Re-Entry-Schutz: ein zweiter Quick-Save waehrend der erste laeuft wuerde read-modify-write
|
||||||
|
// rennen (lost update). Bei aktivem Save den zweiten Druck verwerfen.
|
||||||
|
let qsBusy = false;
|
||||||
|
|
||||||
|
function quickSaveActiveTab() {
|
||||||
|
if (qsBusy) return;
|
||||||
|
qsBusy = true;
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||||
|
const tab = tabs && tabs[0];
|
||||||
|
if (!tab || !tab.url || UNSAVEABLE_URL.test(tab.url)) {
|
||||||
|
// Kein speicherbarer Tab: kurzer roter Marker (langer Text wird im Badge abgeschnitten).
|
||||||
|
chrome.action.setBadgeText({ text: '×' });
|
||||||
|
if (chrome.action.setBadgeBackgroundColor) {
|
||||||
|
chrome.action.setBadgeBackgroundColor({ color: '#c0392b' });
|
||||||
|
}
|
||||||
|
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2000);
|
||||||
|
qsBusy = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Datensicher: NICHT boards schreiben — nur an die Pending-Queue anhaengen (die Seite
|
||||||
|
// drained sie in die Inbox). So kann der Worker das boards-Array nicht clobbern (Review 2b).
|
||||||
|
chrome.storage.local.get(['quicksave_pending'], (r) => {
|
||||||
|
const pending = Array.isArray(r.quicksave_pending) ? r.quicksave_pending : [];
|
||||||
|
pending.push({ id: uid(), title: tab.title || tab.url, url: tab.url });
|
||||||
|
chrome.storage.local.set({ quicksave_pending: pending }, () => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
console.error('Quick-Save fehlgeschlagen:', chrome.runtime.lastError.message);
|
||||||
|
qsBusy = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chrome.action.setBadgeText({ text: chrome.i18n.getMessage('quickSaveBadge') });
|
||||||
|
if (chrome.action.setBadgeBackgroundColor) {
|
||||||
|
chrome.action.setBadgeBackgroundColor({ color: '#1f9d55' });
|
||||||
|
}
|
||||||
|
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2000);
|
||||||
|
qsBusy = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// onCommand SYNCHRON auf Top-Level (additiv neben den Redirect-Listenern).
|
||||||
|
chrome.commands.onCommand.addListener((command) => {
|
||||||
|
if (command === 'quick-save') {
|
||||||
|
quickSaveActiveTab();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — palette.js
|
||||||
|
Command-Palette (Strg+K): read-only Suche ueber Boards/Bookmarks
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
// Reine init-Funktion ohne Top-Level-Seiteneffekt (Hausmuster wie initSearch).
|
||||||
|
// Wird aus app.js init() nach initSearch() aufgerufen.
|
||||||
|
function initPalette() {
|
||||||
|
let overlay = null; // aktives Overlay-Element oder null (= geschlossen)
|
||||||
|
let prevFocus = null; // Fokus-Rueckgabeziel
|
||||||
|
let keyHandler = null; // dokument-weiter Handler im offenen Zustand
|
||||||
|
let results = []; // aktuelle Trefferliste [{ title, url, boardName }]
|
||||||
|
let activeIndex = -1; // aktiver Listeneintrag fuer aria-activedescendant
|
||||||
|
|
||||||
|
// ---- Open-Guard: kein Strg+K wenn ein anderes Overlay offen ist ----
|
||||||
|
// Deckt Settings (.panel-overlay), Theme/Add-Board/Add-Bookmark/Rename
|
||||||
|
// (.modal-overlay) sowie HellionDialog UND Onboarding (.dialog-overlay) ab.
|
||||||
|
function isBlocked() {
|
||||||
|
// .palette-overlay ausklammern: das eigene (beim Schliessen noch deferred .active
|
||||||
|
// tragende) Overlay darf den Reopen-Guard nicht selbst blockieren (Self-Block-Race
|
||||||
|
// beim Toggle-Spam, da close() .active erst in withViewTransition entfernt).
|
||||||
|
return !!document.querySelector(
|
||||||
|
'.panel-overlay.active, .modal-overlay.active, .dialog-overlay.active:not(.palette-overlay)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Trefferquelle: flach ueber alle Boards/Bookmarks ----
|
||||||
|
// Read-only auf dem globalen boards-Array. Match auf Titel, URL, Board-Name.
|
||||||
|
function search(query) {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const board of boards) {
|
||||||
|
const boardName = board.title || '';
|
||||||
|
const boardMatch = boardName.toLowerCase().includes(q);
|
||||||
|
for (const bm of (board.bookmarks || [])) {
|
||||||
|
const title = bm.title || '';
|
||||||
|
const url = bm.url || '';
|
||||||
|
if (
|
||||||
|
title.toLowerCase().includes(q) ||
|
||||||
|
url.toLowerCase().includes(q) ||
|
||||||
|
boardMatch
|
||||||
|
) {
|
||||||
|
out.push({ title, url, boardName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Treffer oeffnen (wie boards.js:270) ----
|
||||||
|
function openResult(item) {
|
||||||
|
if (!item || !item.url) return;
|
||||||
|
// Sicherheit: nur sichere Protokolle oeffnen. Verhindert javascript:/data:-URLs aus
|
||||||
|
// importierten Bookmarks (XSS im Extension-Origin, besonders bei _self). http/https/ftp only.
|
||||||
|
let safe = false;
|
||||||
|
try { safe = ['http:', 'https:', 'ftp:'].includes(new URL(item.url).protocol); } catch (e) { /* ungueltige URL */ }
|
||||||
|
if (!safe) { close(); return; }
|
||||||
|
window.open(item.url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Listbox neu rendern ----
|
||||||
|
function renderList(listEl, liveEl) {
|
||||||
|
listEl.textContent = '';
|
||||||
|
activeIndex = results.length ? 0 : -1;
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
// Kein role="option": der Leerzustand ist keine auswaehlbare Option, sondern eine
|
||||||
|
// Statuszeile. Die Ansage uebernimmt die aria-live-Region (liveEl) unten.
|
||||||
|
const empty = document.createElement('li');
|
||||||
|
empty.className = 'palette-empty';
|
||||||
|
empty.setAttribute('role', 'presentation');
|
||||||
|
empty.textContent = t('palette.no_results');
|
||||||
|
listEl.appendChild(empty);
|
||||||
|
liveEl.textContent = t('palette.no_results');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.forEach((item, i) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'palette-option';
|
||||||
|
li.id = 'palette-opt-' + i;
|
||||||
|
li.setAttribute('role', 'option');
|
||||||
|
li.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
|
||||||
|
|
||||||
|
const titleSpan = document.createElement('span');
|
||||||
|
titleSpan.className = 'palette-option-title';
|
||||||
|
titleSpan.textContent = item.title;
|
||||||
|
|
||||||
|
const metaSpan = document.createElement('span');
|
||||||
|
metaSpan.className = 'palette-option-meta';
|
||||||
|
metaSpan.textContent = t('palette.board_prefix') + ' ' + item.boardName;
|
||||||
|
|
||||||
|
li.append(titleSpan, metaSpan);
|
||||||
|
// Pointer-Auswahl: Klick oeffnet, Hover markiert
|
||||||
|
li.addEventListener('click', () => openResult(item));
|
||||||
|
li.addEventListener('mousemove', () => setActive(listEl, i));
|
||||||
|
listEl.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = results.length;
|
||||||
|
liveEl.textContent = count === 1 ? t('palette.count_one') : t('palette.count', { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- aktiven Eintrag setzen (aria-activedescendant + aria-selected) ----
|
||||||
|
function setActive(listEl, idx) {
|
||||||
|
// Guard: close() nullt overlay synchron, das DOM-Removal laeuft aber deferred in
|
||||||
|
// withViewTransition. In diesem Frame-Fenster kann ein mousemove auf einem noch
|
||||||
|
// lebenden Treffer-<li> setActive() ausloesen -> ohne diesen Guard Null-Deref auf overlay.
|
||||||
|
if (!overlay) return;
|
||||||
|
const options = listEl.querySelectorAll('.palette-option');
|
||||||
|
if (options.length === 0) return;
|
||||||
|
activeIndex = Math.max(0, Math.min(idx, options.length - 1));
|
||||||
|
options.forEach((opt, i) => {
|
||||||
|
opt.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
const input = overlay.querySelector('.palette-input');
|
||||||
|
input.setAttribute('aria-activedescendant', options[activeIndex].id);
|
||||||
|
options[activeIndex].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- schliessen: dialog.js-Cleanup-Muster (remove Listener -> Transition -> Fokus) ----
|
||||||
|
function close() {
|
||||||
|
if (!overlay) return;
|
||||||
|
document.removeEventListener('keydown', keyHandler);
|
||||||
|
const el = overlay;
|
||||||
|
overlay = null;
|
||||||
|
keyHandler = null;
|
||||||
|
withViewTransition(() => {
|
||||||
|
el.classList.remove('active');
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus();
|
||||||
|
prevFocus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- oeffnen: Overlay nach dialog.js-Muster aufbauen ----
|
||||||
|
function open() {
|
||||||
|
if (overlay) return;
|
||||||
|
prevFocus = document.activeElement;
|
||||||
|
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.className = 'dialog-overlay palette-overlay';
|
||||||
|
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'palette-box';
|
||||||
|
box.setAttribute('role', 'none');
|
||||||
|
|
||||||
|
// ARIA-1.2-Combobox: role=combobox gehoert auf das fokussierbare Textfeld selbst,
|
||||||
|
// nicht auf die Huelle. aria-expanded/haspopup/controls/activedescendant ebenfalls hier.
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'palette-input';
|
||||||
|
input.type = 'text';
|
||||||
|
input.setAttribute('role', 'combobox');
|
||||||
|
input.setAttribute('aria-expanded', 'true');
|
||||||
|
input.setAttribute('aria-haspopup', 'listbox');
|
||||||
|
input.setAttribute('aria-label', t('palette.aria_label'));
|
||||||
|
input.setAttribute('aria-autocomplete', 'list');
|
||||||
|
input.setAttribute('aria-controls', 'palette-listbox');
|
||||||
|
input.placeholder = t('palette.placeholder');
|
||||||
|
input.autocomplete = 'off';
|
||||||
|
input.spellcheck = false;
|
||||||
|
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
list.className = 'palette-list';
|
||||||
|
list.id = 'palette-listbox';
|
||||||
|
list.setAttribute('role', 'listbox');
|
||||||
|
list.setAttribute('aria-label', t('palette.list_label'));
|
||||||
|
|
||||||
|
const live = document.createElement('div');
|
||||||
|
live.className = 'palette-live';
|
||||||
|
live.setAttribute('aria-live', 'polite');
|
||||||
|
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.className = 'palette-hint';
|
||||||
|
hint.textContent = t('palette.hint');
|
||||||
|
|
||||||
|
box.append(input, list, live, hint);
|
||||||
|
overlay.appendChild(box);
|
||||||
|
|
||||||
|
// Klick auf den Overlay-Hintergrund schliesst (wie dialog.js:107)
|
||||||
|
overlay.addEventListener('click', e => {
|
||||||
|
if (e.target === overlay) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live-Filter
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
results = search(input.value);
|
||||||
|
renderList(list, live);
|
||||||
|
input.setAttribute('aria-activedescendant', activeIndex >= 0 ? 'palette-opt-' + activeIndex : '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tastatursteuerung: Pfeile/Enter/Escape + Fokus-Falle (nur input fokussierbar)
|
||||||
|
keyHandler = function (e) {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex >= 0 && results[activeIndex]) openResult(results[activeIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(list, activeIndex + 1); return; }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); setActive(list, activeIndex - 1); return; }
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
// Fokus-Falle ueber den Container: das Eingabefeld ist das einzige
|
||||||
|
// fokussierbare Element, also Tab/Shift+Tab immer dorthin zurueck.
|
||||||
|
e.preventDefault();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', keyHandler);
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
// View-Transition uebernimmt das Fade; Fokus ins Eingabefeld
|
||||||
|
withViewTransition(() => {
|
||||||
|
overlay.classList.add('active');
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- globaler Ausloeser Strg+K (Meta+K auf Mac) mit Open-Guard ----
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||||
|
if (overlay) { e.preventDefault(); close(); return; }
|
||||||
|
if (isBlocked()) return;
|
||||||
|
e.preventDefault();
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persistenter Header-Trigger (BS-08): Klick toggelt die Palette wie Strg+K.
|
||||||
|
const paletteBtn = document.getElementById('btnPalette');
|
||||||
|
if (paletteBtn) {
|
||||||
|
paletteBtn.addEventListener('click', () => {
|
||||||
|
if (overlay) { close(); return; }
|
||||||
|
if (isBlocked()) return;
|
||||||
|
open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — quicksave-core.js
|
||||||
|
DOM-freie geteilte Helfer fuer Seite UND Background-Worker.
|
||||||
|
Laeuft als <script> (newtab.html) und via importScripts (Service-Worker/Event-Page).
|
||||||
|
KEIN window/document/Store-Zugriff. Alle Exporte auf globalThis.
|
||||||
|
============================================= */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Feste, nicht-zufaellige ID des Inbox-Boards (Quick-Save-Ziel).
|
||||||
|
// Bewusst KEIN uid(): die Inbox muss von Seite und Worker deterministisch
|
||||||
|
// wiedererkennbar sein, sonst entstehen Duplikate (QS-08).
|
||||||
|
const INBOX_ID = 'inbox';
|
||||||
|
|
||||||
|
// Kollisionsarme Kurz-ID. Identisch zur frueheren state.js-Variante,
|
||||||
|
// damit bestehende Aufrufer (boards.js, data.js, app.js, bookmark-import.js) unveraendert weiterlaufen.
|
||||||
|
function uid() {
|
||||||
|
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sichert, dass im uebergebenen boards-Array genau EIN Inbox-Board existiert,
|
||||||
|
// und gibt dieses Inbox-Board-Objekt zurueck. Idempotent: findet ein Board mit
|
||||||
|
// id === INBOX_ID, sonst legt es eins mit fester id vorne an (unshift) und mutiert
|
||||||
|
// das uebergebene Array dabei IN-PLACE. Der Worker schreibt anschliessend dasselbe
|
||||||
|
// (mutierte) boards-Array via storage zurueck; der Rueckgabewert ist das Board,
|
||||||
|
// damit Aufrufer direkt inbox.bookmarks.push(...) machen koennen (QS-08).
|
||||||
|
function ensureInbox(boardsArr) {
|
||||||
|
const list = Array.isArray(boardsArr) ? boardsArr : [];
|
||||||
|
let inbox = list.find(b => b && b.id === INBOX_ID);
|
||||||
|
if (!inbox) {
|
||||||
|
inbox = { id: INBOX_ID, title: 'Inbox', bookmarks: [], blurred: false };
|
||||||
|
list.unshift(inbox);
|
||||||
|
}
|
||||||
|
return inbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sicheres URL-Protokoll (http/https/ftp). Inhaltlich identisch zur data.js-Variante, aber
|
||||||
|
// DOM-frei und auf globalThis, damit der Quick-Save-Drain (app.js) dieselbe Validierung nutzt
|
||||||
|
// wie jeder andere Bookmark-Schreibpfad. URL ist in Worker UND Seite verfuegbar.
|
||||||
|
function isSafeUrl(url) {
|
||||||
|
try {
|
||||||
|
return ['http:', 'https:', 'ftp:'].includes(new URL(url).protocol);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalisiert eine Bookmark in die kanonische Form { id, title, url, desc }.
|
||||||
|
// title-Fallback auf url, desc auf ''. Begrenzt Laengen wie data.js (200/500),
|
||||||
|
// damit Quick-Save-Eintraege das gleiche Schema wie Import/Manuell haben.
|
||||||
|
function normalizeBookmark(raw) {
|
||||||
|
const url = (raw && typeof raw.url === 'string') ? raw.url : '';
|
||||||
|
const title = (raw && typeof raw.title === 'string' && raw.title.trim())
|
||||||
|
? raw.title.trim()
|
||||||
|
: url;
|
||||||
|
return {
|
||||||
|
id: (raw && raw.id) ? raw.id : uid(),
|
||||||
|
title: String(title).slice(0, 200),
|
||||||
|
url: url,
|
||||||
|
desc: String((raw && raw.desc) || '').slice(0, 500)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
root.INBOX_ID = INBOX_ID;
|
||||||
|
root.uid = uid;
|
||||||
|
root.isSafeUrl = isSafeUrl;
|
||||||
|
root.ensureInbox = ensureInbox;
|
||||||
|
root.normalizeBookmark = normalizeBookmark;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : self);
|
||||||
+317
-20
@@ -3,24 +3,117 @@
|
|||||||
Settings Panel, Theme-Modal, Accordion, Toggles
|
Settings Panel, Theme-Modal, Accordion, Toggles
|
||||||
============================================= */
|
============================================= */
|
||||||
|
|
||||||
|
// ---- A11Y: Fokus-Management fuer Modals ----
|
||||||
|
// Merkt sich das vor dem Oeffnen fokussierte Element, damit wir es beim
|
||||||
|
// Schliessen restaurieren koennen. Pro offenem Modal eine Closure-Variable.
|
||||||
|
const _focusReturn = { settings: null, theme: null };
|
||||||
|
|
||||||
|
/** Liefert die fokussierbaren Elemente innerhalb eines Containers. */
|
||||||
|
function _focusable(container) {
|
||||||
|
return Array.from(container.querySelectorAll(
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
)).filter(el => el.offsetParent !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab/Shift+Tab im Container einfangen + Escape schliesst. */
|
||||||
|
function _makeTrap(container, closeFn) {
|
||||||
|
return function trap(e) {
|
||||||
|
// Ein offener HellionDialog (z.B. Reset-All-Confirm oder BG-URL-Alert aus
|
||||||
|
// dem Panel) hat Vorrang: sein eigener keydown-Handler uebernimmt Escape/Tab.
|
||||||
|
// Sonst schloessen beide Listener gleichzeitig und die Dialog-Fokusfalle wird loechrig.
|
||||||
|
if (document.querySelector('.dialog-overlay')) return;
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); closeFn(); return; }
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
const items = _focusable(container);
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault(); last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault(); first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---- SETTINGS PANEL ----
|
// ---- SETTINGS PANEL ----
|
||||||
|
// Hinweis: withViewTransition (Phase 4) bleibt fuer das Fade erhalten; das
|
||||||
|
// Fokus-Management (merken, Falle, Rueckgabe) liegt bewusst ausserhalb des
|
||||||
|
// Transition-Callbacks. activeElement wird vor der Mutation gelesen.
|
||||||
|
let _settingsTrap = null;
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
document.getElementById('settingsPanel').classList.add('open');
|
const panel = document.getElementById('settingsPanel');
|
||||||
document.getElementById('settingsOverlay').classList.add('active');
|
_focusReturn.settings = document.activeElement;
|
||||||
|
withViewTransition(() => {
|
||||||
|
panel.classList.add('open');
|
||||||
|
document.getElementById('settingsOverlay').classList.add('active');
|
||||||
|
});
|
||||||
|
panel.setAttribute('aria-hidden', 'false');
|
||||||
|
renderTrash();
|
||||||
|
_settingsTrap = _makeTrap(panel, closeSettings);
|
||||||
|
document.addEventListener('keydown', _settingsTrap);
|
||||||
|
const first = _focusable(panel)[0];
|
||||||
|
if (first) first.focus();
|
||||||
}
|
}
|
||||||
function closeSettings() {
|
function closeSettings() {
|
||||||
document.getElementById('settingsPanel').classList.remove('open');
|
const panel = document.getElementById('settingsPanel');
|
||||||
document.getElementById('settingsOverlay').classList.remove('active');
|
withViewTransition(() => {
|
||||||
|
panel.classList.remove('open');
|
||||||
|
document.getElementById('settingsOverlay').classList.remove('active');
|
||||||
|
});
|
||||||
|
panel.setAttribute('aria-hidden', 'true');
|
||||||
|
if (_settingsTrap) { document.removeEventListener('keydown', _settingsTrap); _settingsTrap = null; }
|
||||||
|
if (_focusReturn.settings) { _focusReturn.settings.focus(); _focusReturn.settings = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- THEME MODAL ----
|
// ---- THEME MODAL ----
|
||||||
|
let _themeTrap = null;
|
||||||
function openThemeModal() {
|
function openThemeModal() {
|
||||||
const overlay = document.getElementById('themeOverlay');
|
const overlay = document.getElementById('themeOverlay');
|
||||||
overlay.classList.add('active');
|
const modal = document.getElementById('themeModal');
|
||||||
|
_focusReturn.theme = document.activeElement;
|
||||||
|
withViewTransition(() => {
|
||||||
|
overlay.classList.add('active');
|
||||||
|
});
|
||||||
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
|
_themeTrap = _makeTrap(modal, closeThemeModal);
|
||||||
|
document.addEventListener('keydown', _themeTrap);
|
||||||
|
const first = _focusable(modal)[0];
|
||||||
|
if (first) first.focus();
|
||||||
}
|
}
|
||||||
function closeThemeModal() {
|
function closeThemeModal() {
|
||||||
const overlay = document.getElementById('themeOverlay');
|
const overlay = document.getElementById('themeOverlay');
|
||||||
overlay.classList.remove('active');
|
const modal = document.getElementById('themeModal');
|
||||||
|
withViewTransition(() => {
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
});
|
||||||
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
|
if (_themeTrap) { document.removeEventListener('keydown', _themeTrap); _themeTrap = null; }
|
||||||
|
if (_focusReturn.theme) { _focusReturn.theme.focus(); _focusReturn.theme = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wechselt das Theme mit nativem Cross-Fade (View Transitions API).
|
||||||
|
* Wrap sitzt bewusst hier am User-Ausloeser, NICHT in applyTheme(),
|
||||||
|
* sonst fadet jeder neue Tab beim Initial-Load (settings.js:101).
|
||||||
|
* Feature-Detection-Fallback: aeltere Browser (z.B. Firefox < 144)
|
||||||
|
* schalten instant um, ohne Bruch.
|
||||||
|
* @param {string} name - Theme-Name
|
||||||
|
*/
|
||||||
|
function switchTheme(name) {
|
||||||
|
const swap = () => applyTheme(name, false); // false: Theme-BG anwenden (kein User-bgUrl-Schutz hier noetig, bgUrl wurde geleert)
|
||||||
|
withViewTransition(swap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ----
|
||||||
@@ -49,6 +142,165 @@ function initAccordion() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- PAPIERKORB ----
|
||||||
|
/**
|
||||||
|
* Formatiert einen deletedAt-Timestamp lokalisiert (folgt der aktiven UI-Sprache).
|
||||||
|
* @param {number} ts - Millisekunden-Timestamp
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatTrashDate(ts) {
|
||||||
|
const locale = I18n.currentLang === 'de' ? 'de-DE' : 'en-US';
|
||||||
|
return new Date(ts).toLocaleDateString(locale, { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert den Papierkorb in die Settings-Section. Wird bei jedem openSettings()
|
||||||
|
* sowie nach jeder Trash-Mutation aufgerufen. Baut DOM ohne innerHTML (XSS-frei,
|
||||||
|
* Titel kommen aus User-/Importdaten).
|
||||||
|
*/
|
||||||
|
function renderTrash() {
|
||||||
|
const listEl = document.getElementById('trashList');
|
||||||
|
const actionsRow = document.getElementById('trashActionsRow');
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.replaceChildren();
|
||||||
|
|
||||||
|
if (trash.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'trash-empty';
|
||||||
|
empty.textContent = t('trash.empty');
|
||||||
|
listEl.appendChild(empty);
|
||||||
|
if (actionsRow) actionsRow.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (actionsRow) actionsRow.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Neueste zuerst.
|
||||||
|
const sorted = [...trash].sort((a, b) => b.deletedAt - a.deletedAt);
|
||||||
|
sorted.forEach(entry => listEl.appendChild(createTrashItemEl(entry)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut eine einzelne Papierkorb-Zeile.
|
||||||
|
* @param {Object} entry - trash-Eintrag { item, type, originBoardId, deletedAt }
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
function createTrashItemEl(entry) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'trash-item';
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'trash-item-info';
|
||||||
|
|
||||||
|
const titleLine = document.createElement('span');
|
||||||
|
titleLine.className = 'trash-item-title';
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'trash-item-badge';
|
||||||
|
badge.textContent = entry.type === 'board' ? t('trash.type.board') : t('trash.type.bookmark');
|
||||||
|
const titleText = document.createTextNode(entry.item && entry.item.title ? entry.item.title : '');
|
||||||
|
titleLine.append(badge, titleText);
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'trash-item-meta';
|
||||||
|
let metaText = t('trash.deleted_at', { date: formatTrashDate(entry.deletedAt) });
|
||||||
|
if (entry.type === 'bookmark') {
|
||||||
|
const origin = entry.originBoardId ? boards.find(b => b.id === entry.originBoardId) : null;
|
||||||
|
metaText += origin
|
||||||
|
? ' · ' + t('trash.from_board', { board: origin.title })
|
||||||
|
: ' · ' + t('trash.from_board_unknown');
|
||||||
|
}
|
||||||
|
meta.textContent = metaText;
|
||||||
|
|
||||||
|
info.append(titleLine, meta);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'trash-item-actions';
|
||||||
|
|
||||||
|
const btnRestore = document.createElement('button');
|
||||||
|
btnRestore.className = 'btn-small';
|
||||||
|
btnRestore.textContent = t('trash.restore');
|
||||||
|
btnRestore.title = t('trash.restore_title');
|
||||||
|
btnRestore.addEventListener('click', () => { btnRestore.disabled = true; restoreTrashEntry(entry); });
|
||||||
|
|
||||||
|
const btnForever = document.createElement('button');
|
||||||
|
btnForever.className = 'btn-danger';
|
||||||
|
btnForever.textContent = t('trash.delete_forever');
|
||||||
|
btnForever.title = t('trash.delete_forever_title');
|
||||||
|
btnForever.addEventListener('click', () => deleteTrashEntryForever(entry));
|
||||||
|
|
||||||
|
actions.append(btnRestore, btnForever);
|
||||||
|
row.append(info, actions);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt einen Papierkorb-Eintrag wieder her.
|
||||||
|
* Bookmark -> in originBoardId (falls noch vorhanden), sonst in die Inbox (ensureInboxBoard).
|
||||||
|
* Board -> zurueck in boards[].
|
||||||
|
* @param {Object} entry
|
||||||
|
*/
|
||||||
|
async function restoreTrashEntry(entry) {
|
||||||
|
// Re-Entry-Guard: ein zweiter Klick (z.B. waehrend der Inbox-Alert offen ist) wuerde sonst
|
||||||
|
// das Item ein zweites Mal einfuegen (Duplikat). Nach der ersten Ausfuehrung ist entry
|
||||||
|
// nicht mehr in trash[]; btnRestore wird zusaetzlich beim ersten Klick disabled.
|
||||||
|
if (!trash.includes(entry)) return;
|
||||||
|
if (entry.type === 'board') {
|
||||||
|
// Ganzes Board zurueck (inkl. blurred). Falls die id schon existiert (Edge-Case),
|
||||||
|
// neue uid vergeben, damit nichts ueberschrieben wird.
|
||||||
|
const restored = structuredClone(entry.item);
|
||||||
|
if (boards.some(b => b.id === restored.id)) restored.id = uid();
|
||||||
|
boards.push(restored);
|
||||||
|
await saveBoards();
|
||||||
|
} else {
|
||||||
|
const restored = structuredClone(entry.item);
|
||||||
|
let target = entry.originBoardId ? boards.find(b => b.id === entry.originBoardId) : null;
|
||||||
|
let toInbox = false;
|
||||||
|
if (!target) {
|
||||||
|
// Ursprungs-Board weg -> in die Inbox (Page-Wrapper ensureInboxBoard aus Phase 1).
|
||||||
|
target = await ensureInboxBoard();
|
||||||
|
toInbox = true;
|
||||||
|
}
|
||||||
|
target.bookmarks.push(restored);
|
||||||
|
await saveBoards();
|
||||||
|
if (toInbox) {
|
||||||
|
await HellionDialog.alert(t('trash.restored_to_inbox'), { type: 'info', title: t('trash.restored_to_inbox.title') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trash = trash.filter(e => e !== entry);
|
||||||
|
await saveTrash();
|
||||||
|
renderTrash();
|
||||||
|
renderBoards();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loescht einen einzelnen Papierkorb-Eintrag endgueltig (mit Confirm).
|
||||||
|
* @param {Object} entry
|
||||||
|
*/
|
||||||
|
async function deleteTrashEntryForever(entry) {
|
||||||
|
const ok = await HellionDialog.confirm(
|
||||||
|
t('trash.delete_forever_confirm'),
|
||||||
|
{ type: 'danger', title: t('trash.delete_forever_confirm.title'), confirmText: t('trash.delete_forever') }
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
trash = trash.filter(e => e !== entry);
|
||||||
|
await saveTrash();
|
||||||
|
renderTrash();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leert den gesamten Papierkorb (mit Confirm).
|
||||||
|
*/
|
||||||
|
async function emptyTrash() {
|
||||||
|
if (trash.length === 0) return;
|
||||||
|
const ok = await HellionDialog.confirm(
|
||||||
|
t('trash.empty_confirm', { count: trash.length }),
|
||||||
|
{ type: 'danger', title: t('trash.empty_confirm.title'), confirmText: t('trash.empty_btn') }
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
trash = [];
|
||||||
|
await saveTrash();
|
||||||
|
renderTrash();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- APPLY SETTINGS ----
|
// ---- APPLY SETTINGS ----
|
||||||
function applySettings() {
|
function applySettings() {
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
@@ -78,15 +330,26 @@ function applySettings() {
|
|||||||
const imgRefBtn = document.querySelector('[data-action="image-ref"]');
|
const imgRefBtn = document.querySelector('[data-action="image-ref"]');
|
||||||
if (imgRefBtn) imgRefBtn.classList.toggle('hidden', !settings.imageRefEnabled);
|
if (imgRefBtn) imgRefBtn.classList.toggle('hidden', !settings.imageRefEnabled);
|
||||||
|
|
||||||
|
// A11y: aria-checked aller role=switch-Toggles an den realen checked-State angleichen
|
||||||
|
document.querySelectorAll('.toggle input[role="switch"]').forEach(cb => {
|
||||||
|
cb.setAttribute('aria-checked', cb.checked ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
|
||||||
// Toolbar-Position
|
// Toolbar-Position
|
||||||
document.body.classList.toggle('toolbar-left', settings.toolbarPos === 'left');
|
document.body.classList.toggle('toolbar-left', settings.toolbarPos === 'left');
|
||||||
const toolbarPosEl = document.getElementById('settingToolbarPos');
|
const toolbarPosEl = document.getElementById('settingToolbarPos');
|
||||||
if (toolbarPosEl) toolbarPosEl.value = settings.toolbarPos || 'right';
|
if (toolbarPosEl) toolbarPosEl.value = settings.toolbarPos || 'right';
|
||||||
|
|
||||||
|
// Sprache (Dropdown-Wert setzen — I18n.init() übernimmt die eigentliche Anwendung)
|
||||||
|
const langEl = document.getElementById('settingLanguage');
|
||||||
|
if (langEl) langEl.value = settings.language || 'auto';
|
||||||
|
|
||||||
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
|
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 = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,15 +368,25 @@ function bindSettingsEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Theme-Picker (Cards im Theme-Modal)
|
// Theme-Picker (Cards im Theme-Modal)
|
||||||
document.querySelectorAll('.theme-card').forEach(card => {
|
const themeCards = document.querySelectorAll('.theme-card');
|
||||||
card.addEventListener('click', async () => {
|
function selectThemeCard(card) {
|
||||||
const name = card.dataset.value;
|
const name = card.dataset.value;
|
||||||
if (!name || name === settings.theme) return;
|
if (!name || name === settings.theme) return Promise.resolve();
|
||||||
settings.theme = name;
|
settings.theme = name;
|
||||||
settings.bgUrl = '';
|
settings.bgUrl = '';
|
||||||
document.getElementById('bgUrlInput').value = '';
|
document.getElementById('bgUrlInput').value = '';
|
||||||
applyTheme(name, false);
|
// aria-pressed synchron halten — applyTheme/switchTheme pflegt nur die .active-Klasse, nicht ARIA
|
||||||
await saveSettings();
|
themeCards.forEach(c => c.setAttribute('aria-pressed', c === card ? 'true' : 'false'));
|
||||||
|
switchTheme(name); // WICHTIG: switchTheme aus Phase 4 (View-Transition-Wrapper), NICHT applyTheme direkt — sonst geht der Theme-Fade verloren
|
||||||
|
return saveSettings();
|
||||||
|
}
|
||||||
|
themeCards.forEach(card => {
|
||||||
|
card.addEventListener('click', () => selectThemeCard(card));
|
||||||
|
card.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectThemeCard(card);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,6 +419,7 @@ function bindSettingsEvents() {
|
|||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.addEventListener('change', async e => {
|
el.addEventListener('change', async e => {
|
||||||
|
e.target.setAttribute('aria-checked', e.target.checked ? 'true' : 'false');
|
||||||
fn(e.target.checked);
|
fn(e.target.checked);
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
});
|
});
|
||||||
@@ -164,6 +438,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();
|
||||||
@@ -179,16 +457,28 @@ 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();
|
||||||
};
|
};
|
||||||
reader.onerror = () => {
|
reader.onerror = () => {
|
||||||
HellionDialog.alert('Fehler beim Lesen der Datei. Bitte eine andere Datei wählen.', { type: 'danger', title: 'Dateifehler' });
|
HellionDialog.alert(t('settings.file_read_error'), { type: 'danger', title: t('settings.file_read_error.title') });
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sprach-Einstellung
|
||||||
|
const languageEl = document.getElementById('settingLanguage');
|
||||||
|
if (languageEl) {
|
||||||
|
languageEl.value = settings.language || 'auto';
|
||||||
|
languageEl.addEventListener('change', async (e) => {
|
||||||
|
settings.language = e.target.value;
|
||||||
|
setLanguage(e.target.value);
|
||||||
|
await saveSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Toolbar-Position Setting
|
// Toolbar-Position Setting
|
||||||
const toolbarPosEl = document.getElementById('settingToolbarPos');
|
const toolbarPosEl = document.getElementById('settingToolbarPos');
|
||||||
if (toolbarPosEl) {
|
if (toolbarPosEl) {
|
||||||
@@ -206,20 +496,27 @@ function bindSettingsEvents() {
|
|||||||
Onboarding.start();
|
Onboarding.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Papierkorb leeren
|
||||||
|
const btnEmptyTrash = document.getElementById('btnEmptyTrash');
|
||||||
|
if (btnEmptyTrash) btnEmptyTrash.addEventListener('click', emptyTrash);
|
||||||
|
|
||||||
// Reset All
|
// Reset All
|
||||||
document.getElementById('btnResetAll').addEventListener('click', async () => {
|
document.getElementById('btnResetAll').addEventListener('click', async () => {
|
||||||
const ok = await HellionDialog.confirm(
|
const ok = await HellionDialog.confirm(
|
||||||
'Wirklich alle Boards und Einstellungen löschen? Das kann nicht rückgängig gemacht werden.',
|
t('settings.reset_confirm'),
|
||||||
{ type: 'danger', title: 'Alles zurücksetzen', confirmText: 'Alles löschen' }
|
{ type: 'danger', title: t('settings.reset_confirm.title'), confirmText: t('settings.reset_confirm.button') }
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
boards = [];
|
boards = [];
|
||||||
|
trash = [];
|
||||||
settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false,
|
settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false,
|
||||||
hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula',
|
hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula',
|
||||||
showSearch: true, searchEngine: 'google', toolbarPos: 'right',
|
showSearch: true, searchEngine: 'google', toolbarPos: 'right',
|
||||||
imageRefEnabled: false };
|
imageRefEnabled: false, language: 'auto' };
|
||||||
await saveBoards();
|
await saveBoards();
|
||||||
|
await saveTrash();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
|
setLanguage('auto');
|
||||||
applySettings();
|
applySettings();
|
||||||
renderBoards();
|
renderBoards();
|
||||||
closeSettings();
|
closeSettings();
|
||||||
|
|||||||
+70
-14
@@ -5,6 +5,17 @@
|
|||||||
|
|
||||||
let boards = [];
|
let boards = [];
|
||||||
|
|
||||||
|
// Papierkorb als EIGENER Store-Key (nicht im boards-Payload), isoliert das Quota-Risiko (CR-04/TRASH-02).
|
||||||
|
// Eintrag-Schema: { item, type: 'bookmark'|'board', deletedAt, originBoardId }
|
||||||
|
let trash = [];
|
||||||
|
|
||||||
|
// Papierkorb: Auto-Cleanup-Fenster und harte Obergrenze (Quota-Schutz, TRASH-04).
|
||||||
|
// 30 Tage in Millisekunden; ueber dieser Zeit wird ein Eintrag beim Laden auto-geloescht.
|
||||||
|
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
// Max. Anzahl trash-Eintraege. Bei Ueberlauf werden die aeltesten zuerst verworfen,
|
||||||
|
// damit der Papierkorb nicht das 10-MB-Storage-Limit sprengt (kein blindes Wachstum).
|
||||||
|
const TRASH_MAX_ENTRIES = 100;
|
||||||
|
|
||||||
let settings = {
|
let settings = {
|
||||||
compact: false,
|
compact: false,
|
||||||
shortenTitles: false,
|
shortenTitles: false,
|
||||||
@@ -17,12 +28,12 @@ let settings = {
|
|||||||
showSearch: true,
|
showSearch: true,
|
||||||
searchEngine: 'google',
|
searchEngine: 'google',
|
||||||
toolbarPos: 'right',
|
toolbarPos: 'right',
|
||||||
imageRefEnabled: false
|
imageRefEnabled: false,
|
||||||
|
language: 'auto'
|
||||||
};
|
};
|
||||||
|
|
||||||
function uid() {
|
// uid() lebt jetzt in quicksave-core.js (globalThis.uid), damit Seite und
|
||||||
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
// Background-Worker dieselbe ID-Erzeugung teilen. Hier bewusst KEINE eigene Deklaration.
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(str) {
|
function escHtml(str) {
|
||||||
return String(str)
|
return String(str)
|
||||||
@@ -32,15 +43,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 [
|
||||||
{
|
{
|
||||||
@@ -51,7 +53,9 @@ function getDefaultBoards() {
|
|||||||
{ id: uid(), title: 'MDN Web Docs', url: 'https://developer.mozilla.org', desc: '' },
|
{ id: uid(), title: 'MDN Web Docs', url: 'https://developer.mozilla.org', desc: '' },
|
||||||
{ id: uid(), title: 'Next.js Docs', url: 'https://nextjs.org/docs', desc: '' },
|
{ id: uid(), title: 'Next.js Docs', url: 'https://nextjs.org/docs', desc: '' },
|
||||||
],
|
],
|
||||||
blurred: false
|
blurred: false,
|
||||||
|
locked: false,
|
||||||
|
pos: { x: 40, y: 110 }
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -60,6 +64,58 @@ async function saveBoards() {
|
|||||||
await Store.set('boards', boards);
|
await Store.set('boards', boards);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveTrash() {
|
||||||
|
await Store.set('trash', trash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen Eintrag in den Papierkorb. Klont das Objekt (structuredClone),
|
||||||
|
* damit der trash-Eintrag nicht per Referenz an boards[] haengt und nach dem
|
||||||
|
* Restore-Loop konsistent bleibt. Setzt deletedAt. Erzwingt die harte Obergrenze
|
||||||
|
* TRASH_MAX_ENTRIES (Quota-Schutz, TRASH-04): bei Ueberlauf fallen die aeltesten
|
||||||
|
* Eintraege heraus. Speichern uebernimmt der Aufrufer (saveTrash()).
|
||||||
|
* @param {{ item: Object, type: 'bookmark'|'board', originBoardId: (string|null) }} entry
|
||||||
|
*/
|
||||||
|
function pushToTrash({ item, type, originBoardId }) {
|
||||||
|
const entry = {
|
||||||
|
item: structuredClone(item),
|
||||||
|
type,
|
||||||
|
originBoardId: originBoardId ?? null,
|
||||||
|
deletedAt: Date.now()
|
||||||
|
};
|
||||||
|
trash.push(entry);
|
||||||
|
// Aelteste zuerst kappen, falls die Obergrenze ueberschritten ist.
|
||||||
|
if (trash.length > TRASH_MAX_ENTRIES) {
|
||||||
|
trash.sort((a, b) => a.deletedAt - b.deletedAt);
|
||||||
|
trash = trash.slice(trash.length - TRASH_MAX_ENTRIES);
|
||||||
|
}
|
||||||
|
return entry; // fuer Rollback im Delete-Handler bei Save-Fehler (W-b/Quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-seitiger Wrapper um das DOM-freie ensureInbox() aus quicksave-core.js.
|
||||||
|
// ensureInbox() mutiert das globale boards-Array in-place; wir persistieren nur,
|
||||||
|
// wenn die Inbox neu angelegt wurde, und geben das Inbox-Board-Objekt zurueck
|
||||||
|
// (fuer Quick-Save-/Restore-Pfade).
|
||||||
|
async function ensureInboxBoard() {
|
||||||
|
const before = boards.length;
|
||||||
|
const inbox = ensureInbox(boards); // global aus quicksave-core.js; mutiert boards in-place
|
||||||
|
if (boards.length !== before) {
|
||||||
|
await saveBoards();
|
||||||
|
}
|
||||||
|
return inbox;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
await Store.set('settings', settings);
|
await Store.set('settings', settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- VIEW TRANSITIONS ----
|
||||||
|
// Fuehrt eine synchrone DOM-Mutation mit nativem View-Transition-Fade aus.
|
||||||
|
// Feature-Detection-Fallback (Firefox < 144): instant. reduced-motion kappt das Fade ueber den ungeschichteten @media-Block.
|
||||||
|
function withViewTransition(mutate) {
|
||||||
|
if (document.startViewTransition) {
|
||||||
|
document.startViewTransition(mutate);
|
||||||
|
} else {
|
||||||
|
mutate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ const Store = {
|
|||||||
chrome.storage.local.set({ [key]: value }, () => {
|
chrome.storage.local.set({ [key]: value }, () => {
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
console.error('Storage-Fehler:', chrome.runtime.lastError.message);
|
console.error('Storage-Fehler:', chrome.runtime.lastError.message);
|
||||||
HellionDialog.alert('Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.', { type: 'danger', title: 'Speicher voll' });
|
HellionDialog.alert(t('storage.quota_full'), { type: 'danger', title: t('storage.quota_full.title') });
|
||||||
reject(new Error(chrome.runtime.lastError.message));
|
reject(new Error(chrome.runtime.lastError.message));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ const Store = {
|
|||||||
resolve();
|
resolve();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Storage-Fehler:', e.message);
|
console.error('Storage-Fehler:', e.message);
|
||||||
HellionDialog.alert('Speicher voll! Bitte lösche alte Boards oder das Hintergrundbild, um Platz zu schaffen.', { type: 'danger', title: 'Speicher voll' });
|
HellionDialog.alert(t('storage.quota_full'), { type: 'danger', title: t('storage.quota_full.title') });
|
||||||
reject(e);
|
reject(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-38
@@ -82,7 +82,7 @@ const Timer = {
|
|||||||
|
|
||||||
WidgetManager.create('timer', {
|
WidgetManager.create('timer', {
|
||||||
id: this.WIDGET_ID,
|
id: this.WIDGET_ID,
|
||||||
title: 'Timer',
|
title: t('timer.title'),
|
||||||
x: saved.x || 600,
|
x: saved.x || 600,
|
||||||
y: saved.y || 80,
|
y: saved.y || 80,
|
||||||
width: saved.width || 260,
|
width: saved.width || 260,
|
||||||
@@ -190,7 +190,7 @@ const Timer = {
|
|||||||
const btnStart = document.createElement('button');
|
const btnStart = document.createElement('button');
|
||||||
btnStart.className = 'timer-ctrl-btn primary';
|
btnStart.className = 'timer-ctrl-btn primary';
|
||||||
btnStart.type = 'button';
|
btnStart.type = 'button';
|
||||||
btnStart.textContent = 'Start';
|
btnStart.textContent = t('timer.start');
|
||||||
btnStart.addEventListener('click', () => {
|
btnStart.addEventListener('click', () => {
|
||||||
if (!this._running && this._remaining === 0) {
|
if (!this._running && this._remaining === 0) {
|
||||||
this._applyInput();
|
this._applyInput();
|
||||||
@@ -202,7 +202,7 @@ const Timer = {
|
|||||||
const btnPause = document.createElement('button');
|
const btnPause = document.createElement('button');
|
||||||
btnPause.className = 'timer-ctrl-btn';
|
btnPause.className = 'timer-ctrl-btn';
|
||||||
btnPause.type = 'button';
|
btnPause.type = 'button';
|
||||||
btnPause.textContent = 'Pause';
|
btnPause.textContent = t('timer.pause');
|
||||||
btnPause.disabled = true;
|
btnPause.disabled = true;
|
||||||
btnPause.addEventListener('click', () => this._pause());
|
btnPause.addEventListener('click', () => this._pause());
|
||||||
this._btnPause = btnPause;
|
this._btnPause = btnPause;
|
||||||
@@ -210,7 +210,7 @@ const Timer = {
|
|||||||
const btnReset = document.createElement('button');
|
const btnReset = document.createElement('button');
|
||||||
btnReset.className = 'timer-ctrl-btn danger';
|
btnReset.className = 'timer-ctrl-btn danger';
|
||||||
btnReset.type = 'button';
|
btnReset.type = 'button';
|
||||||
btnReset.textContent = 'Reset';
|
btnReset.textContent = t('timer.reset');
|
||||||
btnReset.addEventListener('click', () => this._reset());
|
btnReset.addEventListener('click', () => this._reset());
|
||||||
this._btnReset = btnReset;
|
this._btnReset = btnReset;
|
||||||
|
|
||||||
@@ -253,13 +253,13 @@ const Timer = {
|
|||||||
|
|
||||||
const title = document.createElement('span');
|
const title = document.createElement('span');
|
||||||
title.className = 'timer-presets-title';
|
title.className = 'timer-presets-title';
|
||||||
title.textContent = 'Presets';
|
title.textContent = t('timer.presets');
|
||||||
|
|
||||||
const addBtn = document.createElement('button');
|
const addBtn = document.createElement('button');
|
||||||
addBtn.className = 'timer-preset-add';
|
addBtn.className = 'timer-preset-add';
|
||||||
addBtn.type = 'button';
|
addBtn.type = 'button';
|
||||||
addBtn.textContent = '+';
|
addBtn.textContent = '+';
|
||||||
addBtn.title = 'Preset speichern';
|
addBtn.title = t('timer.save_preset');
|
||||||
addBtn.addEventListener('click', () => this._showAddPreset(container));
|
addBtn.addEventListener('click', () => this._showAddPreset(container));
|
||||||
|
|
||||||
header.append(title, addBtn);
|
header.append(title, addBtn);
|
||||||
@@ -322,8 +322,8 @@ const Timer = {
|
|||||||
|
|
||||||
if (this._presets.length >= this.MAX_PRESETS) {
|
if (this._presets.length >= this.MAX_PRESETS) {
|
||||||
HellionDialog.alert(
|
HellionDialog.alert(
|
||||||
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_PRESETS + ' Presets speichern.',
|
t('timer.limit_message', { max: this.MAX_PRESETS }),
|
||||||
{ type: 'warning', title: 'Limit erreicht' }
|
{ type: 'warning', title: t('timer.limit_title') }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -334,8 +334,8 @@ const Timer = {
|
|||||||
const parsed = this._parseTimeInput(this._inputEl.value);
|
const parsed = this._parseTimeInput(this._inputEl.value);
|
||||||
if (parsed === 0) {
|
if (parsed === 0) {
|
||||||
HellionDialog.alert(
|
HellionDialog.alert(
|
||||||
'Gib zuerst eine Zeit ein, bevor du ein Preset speicherst.',
|
t('timer.no_time_message'),
|
||||||
{ type: 'info', title: 'Keine Zeit' }
|
{ type: 'info', title: t('timer.no_time_title') }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -347,13 +347,13 @@ const Timer = {
|
|||||||
const nameInput = document.createElement('input');
|
const nameInput = document.createElement('input');
|
||||||
nameInput.className = 'timer-add-input';
|
nameInput.className = 'timer-add-input';
|
||||||
nameInput.type = 'text';
|
nameInput.type = 'text';
|
||||||
nameInput.placeholder = 'Name...';
|
nameInput.placeholder = t('timer.preset_name_placeholder');
|
||||||
nameInput.maxLength = 20;
|
nameInput.maxLength = 20;
|
||||||
|
|
||||||
const confirmBtn = document.createElement('button');
|
const confirmBtn = document.createElement('button');
|
||||||
confirmBtn.className = 'timer-add-confirm';
|
confirmBtn.className = 'timer-add-confirm';
|
||||||
confirmBtn.type = 'button';
|
confirmBtn.type = 'button';
|
||||||
confirmBtn.textContent = 'OK';
|
confirmBtn.textContent = t('timer.ok');
|
||||||
|
|
||||||
const doAdd = async () => {
|
const doAdd = async () => {
|
||||||
const name = nameInput.value.trim();
|
const name = nameInput.value.trim();
|
||||||
@@ -508,9 +508,9 @@ const Timer = {
|
|||||||
_startTitleBlink() {
|
_startTitleBlink() {
|
||||||
this._originalTitle = document.title;
|
this._originalTitle = document.title;
|
||||||
this._blinkIntervalId = setInterval(() => {
|
this._blinkIntervalId = setInterval(() => {
|
||||||
document.title = document.title === '[!] Timer abgelaufen'
|
document.title = document.title === t('timer.finished_title')
|
||||||
? this._originalTitle
|
? this._originalTitle
|
||||||
: '[!] Timer abgelaufen';
|
: t('timer.finished_title');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -521,7 +521,7 @@ const Timer = {
|
|||||||
if (this._blinkIntervalId) {
|
if (this._blinkIntervalId) {
|
||||||
clearInterval(this._blinkIntervalId);
|
clearInterval(this._blinkIntervalId);
|
||||||
this._blinkIntervalId = null;
|
this._blinkIntervalId = null;
|
||||||
document.title = this._originalTitle || 'Hellion Dashboard';
|
document.title = this._originalTitle || t('timer.default_page_title');
|
||||||
}
|
}
|
||||||
this._finished = false;
|
this._finished = false;
|
||||||
this._updateDisplay();
|
this._updateDisplay();
|
||||||
@@ -534,7 +534,7 @@ const Timer = {
|
|||||||
_updateMuteBtn() {
|
_updateMuteBtn() {
|
||||||
if (!this._muteBtn) return;
|
if (!this._muteBtn) return;
|
||||||
this._muteBtn.textContent = this._muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
|
this._muteBtn.textContent = this._muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
|
||||||
this._muteBtn.title = this._muted ? 'Ton einschalten' : 'Ton ausschalten';
|
this._muteBtn.title = this._muted ? t('timer.unmute') : t('timer.mute');
|
||||||
this._muteBtn.classList.toggle('muted', this._muted);
|
this._muteBtn.classList.toggle('muted', this._muted);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -555,7 +555,7 @@ const Timer = {
|
|||||||
_updateControls() {
|
_updateControls() {
|
||||||
if (this._btnStart) {
|
if (this._btnStart) {
|
||||||
this._btnStart.disabled = this._running;
|
this._btnStart.disabled = this._running;
|
||||||
this._btnStart.textContent = this._finished ? 'Neustart' : 'Start';
|
this._btnStart.textContent = this._finished ? t('timer.restart') : t('timer.start');
|
||||||
}
|
}
|
||||||
if (this._btnPause) {
|
if (this._btnPause) {
|
||||||
this._btnPause.disabled = !this._running;
|
this._btnPause.disabled = !this._running;
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+57
-10
@@ -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'
|
||||||
@@ -20,7 +41,7 @@ const WidgetManager = {
|
|||||||
const state = {
|
const state = {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
title: config.title || 'Note',
|
title: config.title || t('notes.default_title'),
|
||||||
x: config.x || 120,
|
x: config.x || 120,
|
||||||
y: config.y || 80,
|
y: config.y || 80,
|
||||||
width: config.width || 280,
|
width: config.width || 280,
|
||||||
@@ -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);
|
||||||
@@ -75,7 +96,7 @@ const WidgetManager = {
|
|||||||
title.addEventListener('blur', async () => {
|
title.addEventListener('blur', async () => {
|
||||||
title.contentEditable = 'false';
|
title.contentEditable = 'false';
|
||||||
const newTitle = title.textContent.trim().slice(0, 20);
|
const newTitle = title.textContent.trim().slice(0, 20);
|
||||||
title.textContent = newTitle || 'Note';
|
title.textContent = newTitle || t('notes.default_title');
|
||||||
const entry = this._widgets.get(state.id);
|
const entry = this._widgets.get(state.id);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
entry.state.title = title.textContent;
|
entry.state.title = title.textContent;
|
||||||
@@ -94,13 +115,13 @@ const WidgetManager = {
|
|||||||
|
|
||||||
const btnMin = document.createElement('button');
|
const btnMin = document.createElement('button');
|
||||||
btnMin.className = 'widget-btn widget-minimize';
|
btnMin.className = 'widget-btn widget-minimize';
|
||||||
btnMin.title = 'Minimieren';
|
btnMin.title = t('widget.minimize');
|
||||||
btnMin.textContent = '\u2500';
|
btnMin.textContent = '\u2500';
|
||||||
btnMin.addEventListener('click', () => this.minimize(state.id));
|
btnMin.addEventListener('click', () => this.minimize(state.id));
|
||||||
|
|
||||||
const btnClose = document.createElement('button');
|
const btnClose = document.createElement('button');
|
||||||
btnClose.className = 'widget-btn widget-close';
|
btnClose.className = 'widget-btn widget-close';
|
||||||
btnClose.title = 'Schließen';
|
btnClose.title = t('widget.close');
|
||||||
btnClose.textContent = '\u2715';
|
btnClose.textContent = '\u2715';
|
||||||
btnClose.addEventListener('click', () => this.close(state.id));
|
btnClose.addEventListener('click', () => this.close(state.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