Compare commits
172 Commits
v1.11.1
...
86f5644cd5
| Author | SHA1 | Date | |
|---|---|---|---|
| 86f5644cd5 | |||
| 4d1ca1bc7e | |||
| 083e78e693 | |||
| 0001de7dd7 | |||
| c985a531ef | |||
| 2af52fc46d | |||
| 1bd2cbb9ad | |||
| d305d37da5 | |||
| 96d4eaa8a1 | |||
| 22e74d41bc | |||
| d0feddbda0 | |||
| 9beeec3182 | |||
| 42e3cf0dec | |||
| 8c509647da | |||
| 2877edee69 | |||
| 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/
|
||||||
+105
@@ -6,6 +6,111 @@ All notable changes per version. Format based on [Keep a Changelog](https://keep
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.4.0] — 2026-06-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Custom theme builder** — A new "Custom" tile in the theme picker opens an inline panel with six colour pickers (accent, background, board surface, and three text levels). Colours apply live to the dashboard; the accent drives the derived glow, border and toggle tints via `color-mix`. A non-blocking WCAG contrast indicator flags hard-to-read text/background combinations without preventing the choice. The custom theme persists across reloads and can be combined with a custom background image. A reset button returns the pickers to neutral defaults. New DE/EN i18n strings; the `<input type="color">` pickers are labelled for accessibility.
|
||||||
|
- **Custom background via https URL** — The background URL field now accepts `https://` images in addition to local uploads (http stays out to avoid mixed content). A privacy note explains that a URL-loaded image is fetched from the remote server on every new tab.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Uploaded background images are downscaled (to the longest screen edge, capped at 2560px) and re-encoded as WebP before storage, to protect the `chrome.storage.local` quota.
|
||||||
|
- The extension-page CSP gains `img-src 'self' https: data: blob:` so https and data-URL backgrounds load deterministically instead of relying on the browser default.
|
||||||
|
- Onboarding slide 3 wording no longer hard-codes a fixed theme count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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.4.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, free layout, command palette, trash, quick save, 11 themes plus a custom theme builder, 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.
|
||||||
@@ -37,8 +38,10 @@ What you see is what's saved. No magic.
|
|||||||
### Boards & Bookmarks
|
### Boards & Bookmarks
|
||||||
|
|
||||||
- Boards as groups for links, sortable via drag & drop
|
- Boards as groups for links, sortable via drag & drop
|
||||||
|
- Free layout: drag boards to any position via a handle, each position is saved; a lock button pins a board in place
|
||||||
- Bookmarks with favicon, title and optional description
|
- Bookmarks with favicon, title and optional description
|
||||||
- Hide boards with the blur button (privacy mode)
|
- Hide boards with the blur button (privacy mode)
|
||||||
|
- Trash: deleted bookmarks and boards are kept for 30 days before removal, with restore from Settings
|
||||||
- HTML import from browser bookmarks (Chrome, Edge, Firefox)
|
- HTML import from browser bookmarks (Chrome, Edge, Firefox)
|
||||||
- JSON export & import (backup & restore)
|
- JSON export & import (backup & restore)
|
||||||
|
|
||||||
@@ -47,6 +50,16 @@ What you see is what's saved. No magic.
|
|||||||
- Google, DuckDuckGo or Bing, switchable with a click
|
- Google, DuckDuckGo or Bing, switchable with a click
|
||||||
- Toggleable via Settings
|
- Toggleable via Settings
|
||||||
|
|
||||||
|
### Command Palette
|
||||||
|
|
||||||
|
- Open with **Ctrl+K**, live-filters all bookmarks (title and URL) and board names from the keyboard
|
||||||
|
- Arrow keys to navigate, Enter opens the match, Escape closes (read-only, separate from the web search bar)
|
||||||
|
|
||||||
|
### Quick Save
|
||||||
|
|
||||||
|
- Global shortcut (default **Alt+Shift+S**) saves the current tab into a fixed Inbox board from any page, without opening the dashboard
|
||||||
|
- A badge confirms the save; an open dashboard tab shows the new bookmark live
|
||||||
|
|
||||||
### Widget System
|
### Widget System
|
||||||
|
|
||||||
- **Notes & Checklists** — Floating note widgets with text or checklist template (max. 5)
|
- **Notes & Checklists** — Floating note widgets with text or checklist template (max. 5)
|
||||||
@@ -57,7 +70,7 @@ What you see is what's saved. No magic.
|
|||||||
- **Widget Toolbar** — Floating buttons for quick access, position (left/right) configurable in Settings
|
- **Widget Toolbar** — Floating buttons for quick access, position (left/right) configurable in Settings
|
||||||
- All widgets: draggable, resizable, z-index stacking on click
|
- All widgets: draggable, resizable, z-index stacking on click
|
||||||
|
|
||||||
### 11 Themes
|
### Themes
|
||||||
|
|
||||||
| Theme | Accent | Style |
|
| Theme | Accent | Style |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -73,6 +86,8 @@ What you see is what's saved. No magic.
|
|||||||
| Avorion | `#2ec4a0` Turquoise | Deep Void |
|
| Avorion | `#2ec4a0` Turquoise | Deep Void |
|
||||||
| Hellion Stealth | `#5ec2ff` Tech Blue | Tactical Recon |
|
| Hellion Stealth | `#5ec2ff` Tech Blue | Tactical Recon |
|
||||||
|
|
||||||
|
Plus a **custom theme**: build your own via the theme picker with six colour pickers (accent, background, board surface and three text levels). Colours apply live, the accent drives the derived glow, border and toggle tints, and a non-blocking WCAG contrast hint flags hard-to-read combinations without blocking the choice. Combinable with your own background image (local upload or https URL).
|
||||||
|
|
||||||
### Image Credits
|
### Image Credits
|
||||||
|
|
||||||
| Theme | Source | License |
|
| Theme | Source | License |
|
||||||
@@ -89,6 +104,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
|
||||||
@@ -97,13 +118,13 @@ What you see is what's saved. No magic.
|
|||||||
|
|
||||||
### Appearance & Settings
|
### Appearance & Settings
|
||||||
|
|
||||||
- **Appearance modal** (header button), theme picker, background image and all display options in one modal
|
- **Appearance modal** (header button), theme picker with custom theme builder, background image (local upload or https URL) and all display options in one modal
|
||||||
- **Settings panel** (header button), widgets, data & help, danger zone
|
- **Settings panel** (header button), widgets, data & help, danger zone
|
||||||
- **About footer**, developer info, license and support links permanently visible
|
- **About footer**, developer info, license and support links permanently visible
|
||||||
- Compact mode, shorten titles, search bar toggle, open links in new tab, descriptions, hide extra bookmarks
|
- 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 +244,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,11 +298,11 @@ 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)
|
||||||
- **Theme System** — CSS Custom Properties, 11 themes, custom background support
|
- **Theme System** — CSS Custom Properties, 11 themes plus a custom theme builder, custom background support (local upload or https URL)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -305,8 +331,8 @@ hellion-newtab/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a release:
|
# Create a release:
|
||||||
git tag v1.10.0
|
git tag v2.4.0
|
||||||
git push origin v1.10.0
|
git push origin v2.4.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.4.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'; img-src 'self' https: data: blob:"
|
||||||
|
},
|
||||||
"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.4.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'; img-src 'self' https: data: blob:"
|
||||||
|
},
|
||||||
"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.4.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'; img-src 'self' https: data: blob:"
|
||||||
|
},
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "assets/icons/icon16.png",
|
"16": "assets/icons/icon16.png",
|
||||||
"48": "assets/icons/icon48.png",
|
"48": "assets/icons/icon48.png",
|
||||||
|
|||||||
+185
-119
@@ -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.4.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,156 @@
|
|||||||
|
|
||||||
<!-- 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 class="theme-card theme-card-custom" data-value="custom" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.custom">
|
||||||
|
<span class="theme-card-custom-swatch"></span>
|
||||||
|
<span class="theme-card-label" data-i18n="theme.builder.title">Eigenes</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="theme-builder-panel hidden" id="themeBuilderPanel">
|
||||||
|
<div class="tb-grid">
|
||||||
|
<div class="tb-picker"><input type="color" id="tbAccent" value="#6c8cff"><label for="tbAccent" data-i18n="theme.builder.accent">Akzent</label></div>
|
||||||
|
<div class="tb-picker"><input type="color" id="tbBg" value="#0b0d12"><label for="tbBg" data-i18n="theme.builder.bg">Hintergrund</label></div>
|
||||||
|
<div class="tb-picker"><input type="color" id="tbBoard" value="#141821"><label for="tbBoard" data-i18n="theme.builder.board">Board-Fläche</label></div>
|
||||||
|
<div class="tb-picker"><input type="color" id="tbText" value="#e6e8ef"><label for="tbText" data-i18n="theme.builder.text">Text primär</label></div>
|
||||||
|
<div class="tb-picker"><input type="color" id="tbTextSec" value="#9aa3b8"><label for="tbTextSec" data-i18n="theme.builder.text_secondary">Text sekundär</label></div>
|
||||||
|
<div class="tb-picker"><input type="color" id="tbTextMuted" value="#5b6478"><label for="tbTextMuted" data-i18n="theme.builder.text_muted">Text gedämpft</label></div>
|
||||||
|
</div>
|
||||||
|
<div class="tb-contrast good" id="tbContrast"><span class="tb-dot"></span><span id="tbContrastText" data-i18n="theme.builder.contrast_good">Gut lesbar</span></div>
|
||||||
|
<div class="tb-foot"><button class="tb-reset" id="tbReset" data-i18n="theme.builder.reset">Zurücksetzen</button></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" data-i18n-placeholder="settings.bg_url.placeholder" 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>
|
||||||
|
<p class="setting-desc bg-url-hint hidden" id="bgUrlHint" data-i18n="settings.bg_url.privacy_hint">Hinweis: Ein per URL eingebundenes Bild wird bei jedem Öffnen vom fremden Server geladen.</p>
|
||||||
<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 +474,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 +490,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 +508,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 +528,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 +541,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
|
||||||
|
}
|
||||||
+704
-148
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.4.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.4.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 => {
|
||||||
|
|||||||
+1040
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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,4 +31,60 @@ chrome.tabs.onActivated.addListener((activeInfo) => {
|
|||||||
chrome.tabs.get(activeInfo.tabId, (tab) => {
|
chrome.tabs.get(activeInfo.tabId, (tab) => {
|
||||||
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);
|
||||||
+500
-24
@@ -3,24 +3,243 @@
|
|||||||
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();
|
||||||
|
syncCustomPickers();
|
||||||
|
document.getElementById('themeBuilderPanel').classList.toggle('hidden', settings.theme !== 'custom');
|
||||||
}
|
}
|
||||||
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/') || url.startsWith('https://'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- THEME-BUILDER: Konstanten + reine Helfer ----
|
||||||
|
const CUSTOM_DEFAULTS = {
|
||||||
|
accent: '#6c8cff', bgPrimary: '#0b0d12', bgBoard: '#141821',
|
||||||
|
textPrimary: '#e6e8ef', textSecondary: '#9aa3b8', textMuted: '#5b6478',
|
||||||
|
};
|
||||||
|
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||||
|
|
||||||
|
function isValidHexColor(v) { return typeof v === 'string' && HEX_RE.test(v); }
|
||||||
|
function safeHex(v, fallback) { return isValidHexColor(v) ? v : fallback; }
|
||||||
|
|
||||||
|
function hexToRgba(hex, alpha) {
|
||||||
|
let h = hex.replace('#', '');
|
||||||
|
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||||
|
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WCAG 2.x Relativluminanz + Kontrastverhaeltnis
|
||||||
|
function relLuminance(hex) {
|
||||||
|
let h = hex.replace('#', '');
|
||||||
|
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||||
|
const lin = [0, 2, 4].map(i => {
|
||||||
|
const c = parseInt(h.slice(i, i + 2), 16) / 255;
|
||||||
|
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||||
|
});
|
||||||
|
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
||||||
|
}
|
||||||
|
function contrastRatio(hexA, hexB) {
|
||||||
|
const a = relLuminance(hexA), b = relLuminance(hexB);
|
||||||
|
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContrastIndicator(textHex, bgHex) {
|
||||||
|
const el = document.getElementById('tbContrast');
|
||||||
|
if (!el) return;
|
||||||
|
const ratio = contrastRatio(textHex, bgHex);
|
||||||
|
let cls, key;
|
||||||
|
if (ratio >= 4.5) { cls = 'good'; key = 'theme.builder.contrast_good'; }
|
||||||
|
else if (ratio >= 3) { cls = 'ok'; key = 'theme.builder.contrast_ok'; }
|
||||||
|
else { cls = 'bad'; key = 'theme.builder.contrast_bad'; }
|
||||||
|
el.classList.remove('good', 'ok', 'bad');
|
||||||
|
el.classList.add(cls);
|
||||||
|
const txt = document.getElementById('tbContrastText');
|
||||||
|
if (txt) txt.textContent = `${t(key)} (${ratio.toFixed(1)}:1)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setzt data-theme='custom' + 6 validierte Inline-Vars (Gate vor jedem setProperty).
|
||||||
|
function applyCustomTheme(ct) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const c = ct || {};
|
||||||
|
const accent = safeHex(c.accent, CUSTOM_DEFAULTS.accent);
|
||||||
|
const bgPrimary = safeHex(c.bgPrimary, CUSTOM_DEFAULTS.bgPrimary);
|
||||||
|
const bgBoard = safeHex(c.bgBoard, CUSTOM_DEFAULTS.bgBoard);
|
||||||
|
const textPrimary = safeHex(c.textPrimary, CUSTOM_DEFAULTS.textPrimary);
|
||||||
|
const textSecondary = safeHex(c.textSecondary, CUSTOM_DEFAULTS.textSecondary);
|
||||||
|
const textMuted = safeHex(c.textMuted, CUSTOM_DEFAULTS.textMuted);
|
||||||
|
|
||||||
|
root.setAttribute('data-theme', 'custom');
|
||||||
|
root.style.setProperty('--accent', accent);
|
||||||
|
root.style.setProperty('--bg-primary', bgPrimary);
|
||||||
|
root.style.setProperty('--bg-board', hexToRgba(bgBoard, 0.55));
|
||||||
|
root.style.setProperty('--text-primary', textPrimary);
|
||||||
|
root.style.setProperty('--text-secondary', textSecondary);
|
||||||
|
root.style.setProperty('--text-muted', textMuted);
|
||||||
|
|
||||||
|
document.querySelectorAll('.theme-card').forEach(card => {
|
||||||
|
const on = card.dataset.value === 'custom';
|
||||||
|
card.classList.toggle('active', on);
|
||||||
|
card.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
updateContrastIndicator(textPrimary, bgPrimary);
|
||||||
|
|
||||||
|
// Kein eigenes Bild gesetzt -> bgLayer leeren, damit --bg-primary (Solid) durchscheint
|
||||||
|
// statt des Hintergrundbilds eines zuvor gewaehlten Presets (das sonst haengen bliebe).
|
||||||
|
if (!(settings.bgUrl && isValidBgUrl(settings.bgUrl))) {
|
||||||
|
document.getElementById('bgLayer').style.backgroundImage = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entfernt die 6 Inline-Vars (Rueckwechsel auf Preset / Reset).
|
||||||
|
function clearCustomTheme() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
['--accent', '--bg-primary', '--bg-board', '--text-primary', '--text-secondary', '--text-muted']
|
||||||
|
.forEach(v => root.style.removeProperty(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schreibt die gespeicherten (oder Default-) Farben in die 6 Picker-Inputs.
|
||||||
|
function syncCustomPickers() {
|
||||||
|
const ct = settings.customTheme || {};
|
||||||
|
const set = (id, key) => { const el = document.getElementById(id); if (el) el.value = safeHex(ct[key], CUSTOM_DEFAULTS[key]); };
|
||||||
|
set('tbAccent', 'accent'); set('tbBg', 'bgPrimary'); set('tbBoard', 'bgBoard');
|
||||||
|
set('tbText', 'textPrimary'); set('tbTextSec', 'textSecondary'); set('tbTextMuted', 'textMuted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eigenes Upload-Bild Quota-schonend verkleinern: auf die laengste Bildschirmkante
|
||||||
|
// (× devicePixelRatio, gedeckelt) herunterrechnen und als WebP neu kodieren. Das spart
|
||||||
|
// gegenueber dem rohen Base64-Upload locker den Grossteil der chrome.storage.local-Quota.
|
||||||
|
// Greift nur beim lokalen Upload (data:-URL ist same-origin, Canvas wird nicht getainted);
|
||||||
|
// https-Hintergruende liegen remote und kosten keine Quota.
|
||||||
|
function downscaleBgImage(dataUrl) {
|
||||||
|
const MAX_DIM = Math.min(2560, Math.round(Math.max(screen.width, screen.height) * (window.devicePixelRatio || 1)));
|
||||||
|
const QUALITY = 0.82;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const scale = Math.min(1, MAX_DIM / Math.max(img.naturalWidth, img.naturalHeight));
|
||||||
|
const w = Math.max(1, Math.round(img.naturalWidth * scale));
|
||||||
|
const h = Math.max(1, Math.round(img.naturalHeight * scale));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) { resolve(dataUrl); return; } // kein 2D-Context -> Original behalten
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
// WebP wo verfuegbar (Chrome/Opera/FF142+); sonst faellt toDataURL auf PNG zurueck -> dann JPEG
|
||||||
|
let out = canvas.toDataURL('image/webp', QUALITY);
|
||||||
|
if (!out.startsWith('data:image/webp')) out = canvas.toDataURL('image/jpeg', QUALITY);
|
||||||
|
resolve(out);
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error('image decode failed'));
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ACCORDION ----
|
// ---- ACCORDION ----
|
||||||
@@ -49,6 +268,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 +456,30 @@ 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';
|
||||||
|
|
||||||
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
|
// Sprache (Dropdown-Wert setzen — I18n.init() übernimmt die eigentliche Anwendung)
|
||||||
|
const langEl = document.getElementById('settingLanguage');
|
||||||
|
if (langEl) langEl.value = settings.language || 'auto';
|
||||||
|
|
||||||
if (settings.bgUrl) {
|
if (settings.theme === 'custom') {
|
||||||
|
applyCustomTheme(settings.customTheme);
|
||||||
|
} else {
|
||||||
|
applyTheme(settings.theme || 'nebula', !!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,18 +498,68 @@ 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) return Promise.resolve();
|
||||||
settings.theme = name;
|
|
||||||
settings.bgUrl = '';
|
// Custom: VOR dem name===settings.theme-Guard, damit ein Re-Klick das Panel wieder oeffnet.
|
||||||
document.getElementById('bgUrlInput').value = '';
|
if (name === 'custom') {
|
||||||
applyTheme(name, false);
|
settings.theme = 'custom';
|
||||||
await saveSettings();
|
if (!settings.customTheme) settings.customTheme = { ...CUSTOM_DEFAULTS };
|
||||||
|
themeCards.forEach(c => c.setAttribute('aria-pressed', c === card ? 'true' : 'false'));
|
||||||
|
applyCustomTheme(settings.customTheme); // setzt data-theme + Inline-Vars; bgUrl UNANGETASTET (Koexistenz)
|
||||||
|
syncCustomPickers();
|
||||||
|
document.getElementById('themeBuilderPanel').classList.remove('hidden');
|
||||||
|
return saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === settings.theme) return Promise.resolve();
|
||||||
|
settings.theme = name;
|
||||||
|
settings.bgUrl = '';
|
||||||
|
document.getElementById('bgUrlInput').value = '';
|
||||||
|
clearCustomTheme(); // Inline-Vars weg beim Rueckwechsel auf ein Preset
|
||||||
|
document.getElementById('themeBuilderPanel').classList.add('hidden');
|
||||||
|
// aria-pressed synchron halten — applyTheme/switchTheme pflegt nur die .active-Klasse, nicht ARIA
|
||||||
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Theme-Builder Picker
|
||||||
|
const TB_PICKERS = [['tbAccent', 'accent'], ['tbBg', 'bgPrimary'], ['tbBoard', 'bgBoard'],
|
||||||
|
['tbText', 'textPrimary'], ['tbTextSec', 'textSecondary'], ['tbTextMuted', 'textMuted']];
|
||||||
|
TB_PICKERS.forEach(([id, key]) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('input', () => { // live waehrend des Ziehens
|
||||||
|
if (!settings.customTheme) settings.customTheme = { ...CUSTOM_DEFAULTS };
|
||||||
|
settings.customTheme[key] = el.value;
|
||||||
|
settings.theme = 'custom';
|
||||||
|
applyCustomTheme(settings.customTheme);
|
||||||
|
});
|
||||||
|
el.addEventListener('change', () => saveSettings()); // persistiert beim Loslassen/Schliessen
|
||||||
|
});
|
||||||
|
|
||||||
|
const tbReset = document.getElementById('tbReset');
|
||||||
|
if (tbReset) {
|
||||||
|
tbReset.addEventListener('click', async () => {
|
||||||
|
settings.customTheme = { ...CUSTOM_DEFAULTS };
|
||||||
|
applyCustomTheme(settings.customTheme);
|
||||||
|
syncCustomPickers();
|
||||||
|
await saveSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Accordion initialisieren
|
// Accordion initialisieren
|
||||||
initAccordion();
|
initAccordion();
|
||||||
|
|
||||||
@@ -146,6 +589,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();
|
||||||
});
|
});
|
||||||
@@ -160,10 +604,16 @@ function bindSettingsEvents() {
|
|||||||
|
|
||||||
// Background URL (im Theme-Modal)
|
// Background URL (im Theme-Modal)
|
||||||
document.getElementById('btnChangeBg').addEventListener('click', () => {
|
document.getElementById('btnChangeBg').addEventListener('click', () => {
|
||||||
document.getElementById('bgInputRow').classList.toggle('hidden');
|
// toggle() liefert true, wenn 'hidden' jetzt gesetzt ist -> Hinweis exakt parallel schalten
|
||||||
|
const isNowHidden = document.getElementById('bgInputRow').classList.toggle('hidden');
|
||||||
|
document.getElementById('bgUrlHint').classList.toggle('hidden', isNowHidden);
|
||||||
});
|
});
|
||||||
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 +629,34 @@ function bindSettingsEvents() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async ev => {
|
reader.onload = async ev => {
|
||||||
settings.bgUrl = ev.target.result;
|
if (!isValidBgUrl(ev.target.result)) return;
|
||||||
document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`;
|
let bg = ev.target.result;
|
||||||
|
try {
|
||||||
|
bg = await downscaleBgImage(bg); // Quota-Schutz: verkleinern + WebP
|
||||||
|
} catch {
|
||||||
|
// Downscale fehlgeschlagen -> Original-Upload nutzen (besser als gar kein Bild)
|
||||||
|
}
|
||||||
|
settings.bgUrl = bg;
|
||||||
|
document.getElementById('bgLayer').style.backgroundImage = `url('${bg}')`;
|
||||||
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 +674,28 @@ 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', customTheme: null };
|
||||||
|
clearCustomTheme();
|
||||||
await saveBoards();
|
await saveBoards();
|
||||||
|
await saveTrash();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
|
setLanguage('auto');
|
||||||
applySettings();
|
applySettings();
|
||||||
renderBoards();
|
renderBoards();
|
||||||
closeSettings();
|
closeSettings();
|
||||||
|
|||||||
+71
-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,
|
||||||
@@ -14,15 +25,16 @@ let settings = {
|
|||||||
visibleCount: 10,
|
visibleCount: 10,
|
||||||
bgUrl: '',
|
bgUrl: '',
|
||||||
theme: 'nebula',
|
theme: 'nebula',
|
||||||
|
customTheme: null,
|
||||||
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 +44,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 +54,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 +65,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