Compare commits
23 Commits
70f3f705b4
...
v2.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d1ca1bc7e | |||
| 083e78e693 | |||
| 0001de7dd7 | |||
| c985a531ef | |||
| 2af52fc46d | |||
| 1bd2cbb9ad | |||
| d305d37da5 | |||
| 96d4eaa8a1 | |||
| 22e74d41bc | |||
| d0feddbda0 | |||
| 9beeec3182 | |||
| 42e3cf0dec | |||
| 8c509647da | |||
| 2877edee69 | |||
| d041c66dfb | |||
| 520a062049 | |||
| 327bcd3385 | |||
| 530196ddf7 | |||
| 17eac64683 | |||
| 1d17f4d11f | |||
| b3288b47eb | |||
| 84976f5a10 | |||
| 5b18bed9b5 |
@@ -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:
|
||||||
|
|||||||
@@ -77,28 +77,47 @@ jobs:
|
|||||||
sha256sum *.zip > checksums-sha256.txt
|
sha256sum *.zip > checksums-sha256.txt
|
||||||
cat checksums-sha256.txt
|
cat checksums-sha256.txt
|
||||||
|
|
||||||
# Gitea-native Release-Action. Legt das Release an, falls der Tag noch
|
# Release per Gitea-API (curl), NICHT via gitea.com/actions/release-action: die ist `using: go`
|
||||||
# keins hat, oder aktualisiert das bestehende und haengt die Assets an.
|
# und stirbt auf diesem Runner mit exit 127 ("go not found"), weil act_runner v0.6.1 die go-Action
|
||||||
# Der auto-injizierte GITHUB_TOKEN auf Gitea Actions hat Gitea-API-Scope
|
# weder im Job-Image noch im Runner kompiliert bekommt. curl + python3 sind im Job-Image vorhanden
|
||||||
# und reicht fuer Release-Write.
|
# und laufen als normaler Step -> unabhaengig von go-Toolchain, Action-Cache und @main-Drift.
|
||||||
- name: Attach to Gitea release
|
# GITHUB_API_URL/GITHUB_REPOSITORY/GITHUB_TOKEN injiziert Gitea Actions automatisch.
|
||||||
uses: https://gitea.com/actions/release-action@main
|
- name: Create release & upload assets (Gitea API)
|
||||||
with:
|
env:
|
||||||
files: |-
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip
|
run: |
|
||||||
dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip
|
set -euo pipefail
|
||||||
dist/checksums-sha256.txt
|
API="${GITHUB_API_URL:-https://gitea.hellion-forge.cloud/api/v1}"
|
||||||
api_key: ${{ secrets.GITHUB_TOKEN }}
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
body: |
|
AUTH="Authorization: token ${GITEA_TOKEN}"
|
||||||
## Hellion NewTab ${{ steps.version.outputs.tag }}
|
|
||||||
|
|
||||||
### Installation
|
# Release-Request-JSON (Body inkl. Installationshinweise) als python-Einzeiler bauen
|
||||||
- **Chrome / Edge / Brave / Vivaldi:** `hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip`
|
# (mehrzeilig wuerde den YAML-run-Block brechen: Zeilen auf Spalte 0).
|
||||||
- **Firefox:** `hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip`
|
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}))')
|
||||||
- **Opera / Opera GX:** `hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip`
|
|
||||||
|
|
||||||
Vollstaendige Installationsanleitung siehe README.
|
# 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
|
||||||
|
|
||||||
### Checksums
|
# Vorhandene gleichnamige Assets entfernen (idempotent bei Re-Runs), dann hochladen.
|
||||||
`checksums-sha256.txt` zum Verifizieren der Dateiintegritaet.
|
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."
|
||||||
|
|||||||
@@ -6,6 +6,33 @@ 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
|
## [2.2.0] — 2026-06-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "__MSG_extName__",
|
"name": "__MSG_extName__",
|
||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"version": "2.2.0",
|
"version": "2.4.0",
|
||||||
"description": "__MSG_extDesc__",
|
"description": "__MSG_extDesc__",
|
||||||
"author": "Hellion Online Media - Florian Wathling",
|
"author": "Hellion Online Media - Florian Wathling",
|
||||||
"homepage_url": "https://hellion-media.de",
|
"homepage_url": "https://hellion-media.de",
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
"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",
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "__MSG_extName__",
|
"name": "__MSG_extName__",
|
||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"version": "2.2.0",
|
"version": "2.4.0",
|
||||||
"description": "__MSG_extDesc__",
|
"description": "__MSG_extDesc__",
|
||||||
"author": "Hellion Online Media - Florian Wathling",
|
"author": "Hellion Online Media - Florian Wathling",
|
||||||
"homepage_url": "https://hellion-media.de",
|
"homepage_url": "https://hellion-media.de",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
"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",
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "__MSG_extName__",
|
"name": "__MSG_extName__",
|
||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"version": "2.2.0",
|
"version": "2.4.0",
|
||||||
"description": "__MSG_extDesc__",
|
"description": "__MSG_extDesc__",
|
||||||
"author": "Hellion Online Media - Florian Wathling",
|
"author": "Hellion Online Media - Florian Wathling",
|
||||||
"homepage_url": "https://hellion-media.de",
|
"homepage_url": "https://hellion-media.de",
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
"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",
|
||||||
|
|||||||
+20
-2
@@ -241,7 +241,7 @@
|
|||||||
<div class="panel-footer">
|
<div class="panel-footer">
|
||||||
<div class="about-block">
|
<div class="about-block">
|
||||||
<div class="about-logo" data-i18n="about.title">⬡ HELLION NEWTAB</div>
|
<div class="about-logo" data-i18n="about.title">⬡ HELLION NEWTAB</div>
|
||||||
<div class="about-version">Version 2.2.0 · 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">
|
||||||
@@ -370,6 +370,23 @@
|
|||||||
<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" data-i18n="settings.section.bg">HINTERGRUND</h3>
|
<h3 class="settings-section-title" data-i18n="settings.section.bg">HINTERGRUND</h3>
|
||||||
@@ -381,9 +398,10 @@
|
|||||||
<button class="btn-small" id="btnChangeBg" data-i18n="settings.bg_change">Ä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" data-i18n="settings.bg_apply">Ü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" data-i18n="settings.bg_upload">Datei hochladen</span>
|
<span class="setting-label" data-i18n="settings.bg_upload">Datei hochladen</span>
|
||||||
|
|||||||
@@ -349,6 +349,30 @@
|
|||||||
--toggle-on-bg: color-mix(in srgb, var(--accent) var(--toggle-on-bg-pct), transparent);
|
--toggle-on-bg: color-mix(in srgb, var(--accent) var(--toggle-on-bg-pct), transparent);
|
||||||
--bg-solid-fallback: #0d0f12;
|
--bg-solid-fallback: #0d0f12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
THEME: CUSTOM (User-Theme-Builder, neutrale Defaults)
|
||||||
|
Inline-Vars aus applyCustomTheme() ueberschreiben die 6 Picker-Werte.
|
||||||
|
============================================ */
|
||||||
|
[data-theme="custom"] {
|
||||||
|
--accent: #6c8cff;
|
||||||
|
--accent-glow-pct: 5%;
|
||||||
|
--board-hover-border-pct: 20%;
|
||||||
|
--logo-shadow-pct: 38%;
|
||||||
|
--toggle-on-bg-pct: 20%;
|
||||||
|
--bg-primary: #0b0d12;
|
||||||
|
--bg-board: rgba(20, 24, 33, 0.55);
|
||||||
|
--border: rgba(255, 255, 255, 0.06);
|
||||||
|
--text-primary: #e6e8ef;
|
||||||
|
--text-secondary: #9aa3b8;
|
||||||
|
--text-muted: #5b6478;
|
||||||
|
--font-display: 'Rajdhani', sans-serif;
|
||||||
|
--font-body: 'Inter', sans-serif;
|
||||||
|
--overlay-bg: radial-gradient(circle at center, color-mix(in srgb, var(--bg-primary) 35%, transparent) 0%, color-mix(in srgb, var(--bg-primary) 88%, transparent) 100%);
|
||||||
|
--header-bg: color-mix(in srgb, var(--bg-primary) 92%, transparent);
|
||||||
|
--toggle-on-bg: color-mix(in srgb, var(--accent) var(--toggle-on-bg-pct), transparent);
|
||||||
|
--bg-solid-fallback: var(--bg-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -413,6 +437,8 @@
|
|||||||
[data-theme="hellion-stealth"] .board-title { text-transform: uppercase; font-size: 0.85rem; letter-spacing: 2px; }
|
[data-theme="hellion-stealth"] .board-title { text-transform: uppercase; font-size: 0.85rem; letter-spacing: 2px; }
|
||||||
[data-theme="hellion-stealth"] .board { border-color: rgba(94, 194, 255, 0.15); backdrop-filter: blur(10px); }
|
[data-theme="hellion-stealth"] .board { border-color: rgba(94, 194, 255, 0.15); backdrop-filter: blur(10px); }
|
||||||
[data-theme="hellion-stealth"] .bm-item:hover { background: rgba(94, 194, 255, 0.10); border-left: 2px solid var(--accent); }
|
[data-theme="hellion-stealth"] .bm-item:hover { background: rgba(94, 194, 255, 0.10); border-left: 2px solid var(--accent); }
|
||||||
|
[data-theme="custom"] .board { border-color: color-mix(in srgb, var(--accent) 15%, transparent); }
|
||||||
|
[data-theme="custom"] .bm-item:hover { background: color-mix(in srgb, var(--accent) 7%, transparent); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -568,9 +594,18 @@ html, body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
/* Ueber dem Blur-Overlay (.board-blur-overlay, z-index:5) liegen, sonst schluckt das
|
||||||
|
Overlay den pointerdown und ein geblurrtes Board ist nicht verschiebbar. Der Rest des
|
||||||
|
Boards bleibt unter dem Overlay -> Klick dort enthuellt weiterhin (Phase-5-Review). */
|
||||||
|
position: relative;
|
||||||
|
z-index: 6;
|
||||||
}
|
}
|
||||||
.board-drag-handle:hover { color: var(--accent); }
|
.board-drag-handle:hover { color: var(--accent); }
|
||||||
.board-drag-handle:active { cursor: grabbing; }
|
.board-drag-handle:active { cursor: grabbing; }
|
||||||
|
/* Gesperrtes Board (Position fixiert): Drag-Handle ausblenden (kein Verschieben mehr),
|
||||||
|
Lock-Button aktiv einfaerben. Hoehere Spezifitaet als .board-drag-handle -> display:none gewinnt. */
|
||||||
|
.board.locked .board-drag-handle { display: none; }
|
||||||
|
.board.locked .btn-lock-board { color: var(--accent); }
|
||||||
|
|
||||||
.board-title {
|
.board-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
@@ -2378,6 +2413,35 @@ body.show-desc .bm-desc { display: block; }
|
|||||||
.theme-modal-section .setting-row {
|
.theme-modal-section .setting-row {
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
.bg-url-hint {
|
||||||
|
padding: 2px 0 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.theme-card-custom-swatch {
|
||||||
|
width: 100%; height: 56px; border-radius: 8px;
|
||||||
|
border: 1.5px dashed var(--accent);
|
||||||
|
background: repeating-linear-gradient(45deg, rgba(255,255,255,0.03) 0 6px, rgba(255,255,255,0.06) 6px 12px);
|
||||||
|
}
|
||||||
|
.theme-builder-panel { padding: 10px 0 4px; }
|
||||||
|
.theme-builder-panel.hidden { display: none; }
|
||||||
|
.tb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 18px; }
|
||||||
|
.tb-picker { display: flex; align-items: center; gap: 9px; }
|
||||||
|
.tb-picker input[type="color"] {
|
||||||
|
width: 30px; height: 30px; padding: 0; border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; background: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.tb-picker label { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.tb-contrast { margin-top: 12px; font-size: 12px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.tb-contrast .tb-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; }
|
||||||
|
.tb-contrast.good { color: #7bd88f; } .tb-contrast.good .tb-dot { background: #3fbf6f; }
|
||||||
|
.tb-contrast.ok { color: #e3c97a; } .tb-contrast.ok .tb-dot { background: #d8b24a; }
|
||||||
|
.tb-contrast.bad { color: #e58f8f; } .tb-contrast.bad .tb-dot { background: #d65c5c; }
|
||||||
|
.tb-foot { display: flex; justify-content: flex-end; margin-top: 10px; }
|
||||||
|
.tb-reset {
|
||||||
|
font-size: 11px; color: var(--text-secondary); background: none;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; padding: 5px 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) { .tb-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
ACCORDION SETTINGS
|
ACCORDION SETTINGS
|
||||||
|
|||||||
+31
-5
@@ -119,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: '2.2.0', exported: new Date().toISOString(), boards, settings, trash, 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');
|
||||||
@@ -241,6 +241,7 @@ function bindGlobalEvents() {
|
|||||||
// gegenseitig ueberschreiben (Datensicherheit, Phase-4-Review-Blocker 2b).
|
// gegenseitig ueberschreiben (Datensicherheit, Phase-4-Review-Blocker 2b).
|
||||||
let _drainBusy = false;
|
let _drainBusy = false;
|
||||||
let _drainQueued = false; // ein waehrend eines laufenden Drains angefragter Drain wird nachgeholt
|
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() {
|
async function drainQuickSavePending() {
|
||||||
if (_drainBusy) { _drainQueued = true; return; }
|
if (_drainBusy) { _drainQueued = true; return; }
|
||||||
_drainBusy = true;
|
_drainBusy = true;
|
||||||
@@ -250,10 +251,19 @@ async function drainQuickSavePending() {
|
|||||||
const drained = pending.slice();
|
const drained = pending.slice();
|
||||||
const drainedIds = new Set(drained.map(e => e && e.id).filter(Boolean));
|
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
|
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) {
|
for (const e of drained) {
|
||||||
if (e && typeof e.url === 'string' && e.url) {
|
if (!e || !e.id || seenSrc.has(e.id)) continue; // schon eingespielt
|
||||||
inbox.bookmarks.push(normalizeBookmark({ title: e.title, url: e.url }));
|
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();
|
await saveBoards();
|
||||||
// NUR die verarbeiteten Eintraege entfernen — ein gleichzeitiger Worker-Append bleibt erhalten.
|
// NUR die verarbeiteten Eintraege entfernen — ein gleichzeitiger Worker-Append bleibt erhalten.
|
||||||
@@ -261,7 +271,14 @@ async function drainQuickSavePending() {
|
|||||||
const remaining = Array.isArray(still) ? still.filter(e => e && !drainedIds.has(e.id)) : [];
|
const remaining = Array.isArray(still) ? still.filter(e => e && !drainedIds.has(e.id)) : [];
|
||||||
await Store.set('quicksave_pending', remaining);
|
await Store.set('quicksave_pending', remaining);
|
||||||
// Render nur, wenn gerade KEIN Drag laeuft (renderBoards->replaceChildren wuerde ihn abreissen).
|
// Render nur, wenn gerade KEIN Drag laeuft (renderBoards->replaceChildren wuerde ihn abreissen).
|
||||||
if (!document.querySelector('.board.dragging, .bm-item.dragging-source')) renderBoards();
|
// 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) {
|
} catch (e) {
|
||||||
console.error('Quick-Save-Drain fehlgeschlagen:', e && e.message);
|
console.error('Quick-Save-Drain fehlgeschlagen:', e && e.message);
|
||||||
@@ -273,6 +290,15 @@ async function drainQuickSavePending() {
|
|||||||
if (_drainQueued) { _drainQueued = false; drainQuickSavePending(); }
|
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.
|
// Live-Sync (QS-03): ein offener NewTab drained die Queue, sobald der Worker etwas anhaengt.
|
||||||
function bindStorageSync() {
|
function bindStorageSync() {
|
||||||
if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.onChanged) return;
|
if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.onChanged) return;
|
||||||
|
|||||||
+25
-3
@@ -46,6 +46,13 @@ 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 ----
|
// ---- POS-MIGRATION ----
|
||||||
// Boards ohne pos (Altbestand vor v2.3) aus einem Auto-Raster befuellen,
|
// 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
|
// damit sie sich nicht alle auf (0,0) stapeln. Raster orientiert sich am
|
||||||
@@ -113,7 +120,7 @@ function renderBoards() {
|
|||||||
|
|
||||||
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
|
||||||
@@ -133,6 +140,11 @@ 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 ? t('boards.unblur') : t('boards.blur');
|
btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur');
|
||||||
@@ -152,9 +164,9 @@ function createBoardEl(board) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (btnDelete) {
|
if (btnDelete) {
|
||||||
actions.append(btnBlur, btnRename, btnDelete);
|
actions.append(btnLock, btnBlur, btnRename, btnDelete);
|
||||||
} else {
|
} else {
|
||||||
actions.append(btnBlur, btnRename);
|
actions.append(btnLock, btnBlur, btnRename);
|
||||||
}
|
}
|
||||||
header.append(dragHandle, titleSpanHeader, actions);
|
header.append(dragHandle, titleSpanHeader, actions);
|
||||||
|
|
||||||
@@ -163,6 +175,16 @@ 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;
|
||||||
|
|||||||
+15
-7
@@ -39,7 +39,7 @@ function initDataButtons() {
|
|||||||
btnExport.addEventListener('click', async () => {
|
btnExport.addEventListener('click', async () => {
|
||||||
const widgetData = await Store.get('widgetStates');
|
const widgetData = await Store.get('widgetStates');
|
||||||
const data = {
|
const data = {
|
||||||
version: '2.2.0',
|
version: '2.4.0',
|
||||||
exported: new Date().toISOString(),
|
exported: new Date().toISOString(),
|
||||||
boards,
|
boards,
|
||||||
settings,
|
settings,
|
||||||
@@ -72,6 +72,7 @@ function initDataButtons() {
|
|||||||
id: b.id || uid(),
|
id: b.id || uid(),
|
||||||
title: String(b.title).slice(0, 100),
|
title: String(b.title).slice(0, 100),
|
||||||
blurred: !!b.blurred,
|
blurred: !!b.blurred,
|
||||||
|
locked: !!b.locked,
|
||||||
bookmarks: b.bookmarks
|
bookmarks: b.bookmarks
|
||||||
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
|
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
|
||||||
.map(bm => ({
|
.map(bm => ({
|
||||||
@@ -109,6 +110,7 @@ function initDataButtons() {
|
|||||||
id: e.item.id || uid(),
|
id: e.item.id || uid(),
|
||||||
title: String(e.item.title || '').slice(0, 100),
|
title: String(e.item.title || '').slice(0, 100),
|
||||||
blurred: !!e.item.blurred,
|
blurred: !!e.item.blurred,
|
||||||
|
locked: !!e.item.locked,
|
||||||
// Position erhalten, damit ein wiederhergestelltes Board an seinem Platz landet.
|
// Position erhalten, damit ein wiederhergestelltes Board an seinem Platz landet.
|
||||||
...(safePos(e.item.pos) ? { pos: safePos(e.item.pos) } : {}),
|
...(safePos(e.item.pos) ? { pos: safePos(e.item.pos) } : {}),
|
||||||
bookmarks: Array.isArray(e.item.bookmarks)
|
bookmarks: Array.isArray(e.item.bookmarks)
|
||||||
@@ -124,12 +126,18 @@ function initDataButtons() {
|
|||||||
}))
|
}))
|
||||||
.filter(e => e.item !== null);
|
.filter(e => e.item !== null);
|
||||||
if (validTrash.length > 0) {
|
if (validTrash.length > 0) {
|
||||||
// Nach deletedAt aufsteigend sortieren, DANN die neuesten TRASH_MAX_ENTRIES behalten.
|
// Lokale Eintraege sind die EINZIGE Kopie ihrer geloeschten Daten -> Vorrang. Importierte
|
||||||
// Positionsbasiertes slice(-N) wuerde sonst frische lokale Eintraege verdraengen
|
// stammen aus einem Backup, das der Nutzer noch besitzt -> nachrangig. Daher: erst ALLE
|
||||||
// statt der aeltesten — Datenverlust, da ein Trash-Eintrag die einzige Kopie ist.
|
// lokalen behalten (pushToTrash kappt sie bereits auf TRASH_MAX_ENTRIES), dann mit den
|
||||||
const combined = [...trash, ...validTrash];
|
// NEUESTEN importierten bis zur Obergrenze auffuellen. Ein frischer Import verdraengt so
|
||||||
combined.sort((a, b) => a.deletedAt - b.deletedAt);
|
// keine aelteren lokalen Sole-Copies mehr (frueheres sort+slice(-N) konnte das, data-loss).
|
||||||
trash = combined.slice(-TRASH_MAX_ENTRIES);
|
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();
|
await saveTrash();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-1
@@ -21,6 +21,9 @@ function initBoardDragDrop() {
|
|||||||
handle.addEventListener('pointerdown', function onDown(e) {
|
handle.addEventListener('pointerdown', function onDown(e) {
|
||||||
// Auf Mobil ist .board position:static (Stapel) -> kein Free-Move.
|
// Auf Mobil ist .board position:static (Stapel) -> kein Free-Move.
|
||||||
if (getComputedStyle(boardEl).position !== 'absolute') return;
|
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);
|
||||||
// .board.dragging hebt das Board per CSS nach vorne (z-index) UND ist das Signal fuer den
|
// .board.dragging hebt das Board per CSS nach vorne (z-index) UND ist das Signal fuer den
|
||||||
@@ -31,8 +34,16 @@ function initBoardDragDrop() {
|
|||||||
const rect = boardEl.getBoundingClientRect();
|
const rect = boardEl.getBoundingClientRect();
|
||||||
const offX = e.clientX - rect.left;
|
const offX = e.clientX - rect.left;
|
||||||
const offY = 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;
|
||||||
|
|
||||||
function onMove(ev) {
|
function onMove(ev) {
|
||||||
|
if (Math.abs(ev.clientX - startCX) > 3 || Math.abs(ev.clientY - startCY) > 3) moved = true;
|
||||||
const maxX = window.innerWidth - boardEl.offsetWidth;
|
const maxX = window.innerWidth - boardEl.offsetWidth;
|
||||||
const maxY = window.innerHeight - boardEl.offsetHeight;
|
const maxY = window.innerHeight - boardEl.offsetHeight;
|
||||||
const x = Math.max(0, Math.min(maxX, ev.clientX - offX));
|
const x = Math.max(0, Math.min(maxX, ev.clientX - offX));
|
||||||
@@ -54,6 +65,8 @@ function initBoardDragDrop() {
|
|||||||
|
|
||||||
async function onUp() {
|
async function onUp() {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
// Nur bei echtem Verschieben persistieren — sonst board.pos unangetastet lassen.
|
||||||
|
if (moved) {
|
||||||
const id = boardEl.dataset.boardId;
|
const id = boardEl.dataset.boardId;
|
||||||
const board = boards.find(b => b.id === id);
|
const board = boards.find(b => b.id === id);
|
||||||
if (board) {
|
if (board) {
|
||||||
@@ -64,10 +77,16 @@ function initBoardDragDrop() {
|
|||||||
await saveBoards();
|
await saveBoards();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Einen waehrend des Drags ausgelassenen Quick-Save-Render nachholen (app.js).
|
||||||
|
if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
|
||||||
|
}
|
||||||
|
|
||||||
// pointercancel feuert STATT pointerup bei Touch-Interrupt, Browser-Geste oder wenn das
|
// pointercancel feuert STATT pointerup bei Touch-Interrupt, Browser-Geste oder wenn das
|
||||||
// captured Element aus dem DOM faellt -> nur aufraeumen (cleanup), pos NICHT ueberschreiben.
|
// captured Element aus dem DOM faellt -> nur aufraeumen (cleanup), pos NICHT ueberschreiben.
|
||||||
function onCancel() { cleanup(); }
|
function onCancel() {
|
||||||
|
cleanup();
|
||||||
|
if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
|
||||||
|
}
|
||||||
|
|
||||||
handle.addEventListener('pointermove', onMove);
|
handle.addEventListener('pointermove', onMove);
|
||||||
handle.addEventListener('pointerup', onUp);
|
handle.addEventListener('pointerup', onUp);
|
||||||
@@ -91,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 => {
|
||||||
|
|||||||
+40
-8
@@ -21,6 +21,8 @@ const STRINGS = {
|
|||||||
'boards.drag_title': 'Board verschieben',
|
'boards.drag_title': 'Board verschieben',
|
||||||
'boards.blur': 'Blur (privat)',
|
'boards.blur': 'Blur (privat)',
|
||||||
'boards.unblur': 'Unblur',
|
'boards.unblur': 'Unblur',
|
||||||
|
'boards.lock': 'Position sperren',
|
||||||
|
'boards.unlock': 'Position entsperren',
|
||||||
'boards.rename': 'Umbenennen',
|
'boards.rename': 'Umbenennen',
|
||||||
'boards.delete': 'Löschen',
|
'boards.delete': 'Löschen',
|
||||||
'boards.delete_confirm': 'Board „{title}" wirklich löschen?',
|
'boards.delete_confirm': 'Board „{title}" wirklich löschen?',
|
||||||
@@ -64,8 +66,8 @@ const STRINGS = {
|
|||||||
'onboarding.s2.f2': 'Importiere Browser-Lesezeichen über den „Import" Button im Header',
|
'onboarding.s2.f2': 'Importiere Browser-Lesezeichen über den „Import" Button im Header',
|
||||||
'onboarding.s2.f3': 'Drag & Drop zum Umsortieren von Boards und Links',
|
'onboarding.s2.f3': 'Drag & Drop zum Umsortieren von Boards und Links',
|
||||||
'onboarding.s2.f4': 'Blur-Modus für private Boards (🔒 Icon)',
|
'onboarding.s2.f4': 'Blur-Modus für private Boards (🔒 Icon)',
|
||||||
'onboarding.s3.title': '11 handgefertigte Themes',
|
'onboarding.s3.title': 'Handgefertigte Themes + dein eigenes',
|
||||||
'onboarding.s3.text': 'Klicke auf den „Theme" Button im Header um dein Theme zu wählen. Jedes hat seinen eigenen Stil und Farbpalette.',
|
'onboarding.s3.text': 'Klicke auf den „Theme" Button im Header, um ein Theme zu wählen oder dir ein eigenes zu bauen. Jedes hat seinen eigenen Stil.',
|
||||||
'onboarding.s4.title': 'Widget-Toolbar',
|
'onboarding.s4.title': 'Widget-Toolbar',
|
||||||
'onboarding.s4.f1': 'Die schwebenden Buttons rechts öffnen Widgets',
|
'onboarding.s4.f1': 'Die schwebenden Buttons rechts öffnen Widgets',
|
||||||
'onboarding.s4.f2': 'Notes und Checklisten für schnelle Notizen',
|
'onboarding.s4.f2': 'Notes und Checklisten für schnelle Notizen',
|
||||||
@@ -383,7 +385,9 @@ const STRINGS = {
|
|||||||
'settings.visible_count': 'Sichtbare Bookmarks',
|
'settings.visible_count': 'Sichtbare Bookmarks',
|
||||||
'settings.visible_count.desc': 'Anzahl vor dem Ausblenden',
|
'settings.visible_count.desc': 'Anzahl vor dem Ausblenden',
|
||||||
'settings.bg_url': 'Bild-URL',
|
'settings.bg_url': 'Bild-URL',
|
||||||
'settings.bg_url.desc': 'Eigenes Hintergrundbild per URL',
|
'settings.bg_url.desc': 'Eigenes Bild per https-URL oder lokalem Upload',
|
||||||
|
'settings.bg_url.placeholder': 'https://… oder leer für Standard',
|
||||||
|
'settings.bg_url.privacy_hint': 'Hinweis: Ein per URL eingebundenes Bild wird bei jedem Öffnen vom fremden Server geladen.',
|
||||||
'settings.bg_change': 'Ändern',
|
'settings.bg_change': 'Ändern',
|
||||||
'settings.bg_apply': 'Übernehmen',
|
'settings.bg_apply': 'Übernehmen',
|
||||||
'settings.bg_upload': 'Datei hochladen',
|
'settings.bg_upload': 'Datei hochladen',
|
||||||
@@ -394,7 +398,7 @@ const STRINGS = {
|
|||||||
'settings.onboarding_btn': 'Start',
|
'settings.onboarding_btn': 'Start',
|
||||||
'settings.reset_btn': 'Reset',
|
'settings.reset_btn': 'Reset',
|
||||||
'settings.bg_upload_btn': 'Upload',
|
'settings.bg_upload_btn': 'Upload',
|
||||||
'settings.bg_invalid_url': 'Nur lokale Bilder (Upload) sind als Hintergrund erlaubt.',
|
'settings.bg_invalid_url': 'Nur https-URLs oder lokale Bilder (Upload) sind als Hintergrund erlaubt.',
|
||||||
'settings.bg_invalid_url.title': 'Ungültige URL',
|
'settings.bg_invalid_url.title': 'Ungültige URL',
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
@@ -421,6 +425,18 @@ const STRINGS = {
|
|||||||
'theme.card.satisfactory': 'Theme Satisfactory wählen',
|
'theme.card.satisfactory': 'Theme Satisfactory wählen',
|
||||||
'theme.card.avorion': 'Theme Avorion wählen',
|
'theme.card.avorion': 'Theme Avorion wählen',
|
||||||
'theme.card.hellion_stealth': 'Theme Hellion Stealth wählen',
|
'theme.card.hellion_stealth': 'Theme Hellion Stealth wählen',
|
||||||
|
'theme.card.custom': 'Eigenes Theme wählen',
|
||||||
|
'theme.builder.title': 'Eigenes',
|
||||||
|
'theme.builder.accent': 'Akzent',
|
||||||
|
'theme.builder.bg': 'Hintergrund',
|
||||||
|
'theme.builder.board': 'Board-Fläche',
|
||||||
|
'theme.builder.text': 'Text primär',
|
||||||
|
'theme.builder.text_secondary': 'Text sekundär',
|
||||||
|
'theme.builder.text_muted': 'Text gedämpft',
|
||||||
|
'theme.builder.reset': 'Zurücksetzen',
|
||||||
|
'theme.builder.contrast_good': 'Gut lesbar',
|
||||||
|
'theme.builder.contrast_ok': 'Grenzwertig',
|
||||||
|
'theme.builder.contrast_bad': 'Schwer lesbar',
|
||||||
'toolbar.label': 'Widget-Werkzeugleiste',
|
'toolbar.label': 'Widget-Werkzeugleiste',
|
||||||
|
|
||||||
// About
|
// About
|
||||||
@@ -480,6 +496,8 @@ const STRINGS = {
|
|||||||
'boards.drag_title': 'Move board',
|
'boards.drag_title': 'Move board',
|
||||||
'boards.blur': 'Blur (private)',
|
'boards.blur': 'Blur (private)',
|
||||||
'boards.unblur': 'Unblur',
|
'boards.unblur': 'Unblur',
|
||||||
|
'boards.lock': 'Lock position',
|
||||||
|
'boards.unlock': 'Unlock position',
|
||||||
'boards.rename': 'Rename',
|
'boards.rename': 'Rename',
|
||||||
'boards.delete': 'Delete',
|
'boards.delete': 'Delete',
|
||||||
'boards.delete_confirm': 'Really delete board "{title}"?',
|
'boards.delete_confirm': 'Really delete board "{title}"?',
|
||||||
@@ -523,8 +541,8 @@ const STRINGS = {
|
|||||||
'onboarding.s2.f2': 'Import browser bookmarks via the "Import" button in the header',
|
'onboarding.s2.f2': 'Import browser bookmarks via the "Import" button in the header',
|
||||||
'onboarding.s2.f3': 'Drag & drop to reorder boards and links',
|
'onboarding.s2.f3': 'Drag & drop to reorder boards and links',
|
||||||
'onboarding.s2.f4': 'Blur mode for private boards (🔒 icon)',
|
'onboarding.s2.f4': 'Blur mode for private boards (🔒 icon)',
|
||||||
'onboarding.s3.title': '11 handcrafted themes',
|
'onboarding.s3.title': 'Hand-crafted themes + your own',
|
||||||
'onboarding.s3.text': 'Click the "Theme" button in the header to choose your theme. Each has its own style and color palette.',
|
'onboarding.s3.text': 'Click the "Theme" button in the header to choose a theme or build your own. Each has its own style.',
|
||||||
'onboarding.s4.title': 'Widget Toolbar',
|
'onboarding.s4.title': 'Widget Toolbar',
|
||||||
'onboarding.s4.f1': 'The floating buttons on the right open widgets',
|
'onboarding.s4.f1': 'The floating buttons on the right open widgets',
|
||||||
'onboarding.s4.f2': 'Notes and checklists for quick notes',
|
'onboarding.s4.f2': 'Notes and checklists for quick notes',
|
||||||
@@ -842,7 +860,9 @@ const STRINGS = {
|
|||||||
'settings.visible_count': 'Visible bookmarks',
|
'settings.visible_count': 'Visible bookmarks',
|
||||||
'settings.visible_count.desc': 'Number before hiding',
|
'settings.visible_count.desc': 'Number before hiding',
|
||||||
'settings.bg_url': 'Image URL',
|
'settings.bg_url': 'Image URL',
|
||||||
'settings.bg_url.desc': 'Custom background image via URL',
|
'settings.bg_url.desc': 'Custom image via https URL or local upload',
|
||||||
|
'settings.bg_url.placeholder': 'https://… or empty for default',
|
||||||
|
'settings.bg_url.privacy_hint': 'Note: an image loaded via URL is fetched from the remote server every time you open a tab.',
|
||||||
'settings.bg_change': 'Change',
|
'settings.bg_change': 'Change',
|
||||||
'settings.bg_apply': 'Apply',
|
'settings.bg_apply': 'Apply',
|
||||||
'settings.bg_upload': 'Upload file',
|
'settings.bg_upload': 'Upload file',
|
||||||
@@ -853,7 +873,7 @@ const STRINGS = {
|
|||||||
'settings.onboarding_btn': 'Start',
|
'settings.onboarding_btn': 'Start',
|
||||||
'settings.reset_btn': 'Reset',
|
'settings.reset_btn': 'Reset',
|
||||||
'settings.bg_upload_btn': 'Upload',
|
'settings.bg_upload_btn': 'Upload',
|
||||||
'settings.bg_invalid_url': 'Only local images (upload) are allowed as background.',
|
'settings.bg_invalid_url': 'Only https URLs or local images (upload) are allowed as background.',
|
||||||
'settings.bg_invalid_url.title': 'Invalid URL',
|
'settings.bg_invalid_url.title': 'Invalid URL',
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
@@ -880,6 +900,18 @@ const STRINGS = {
|
|||||||
'theme.card.satisfactory': 'Select Satisfactory theme',
|
'theme.card.satisfactory': 'Select Satisfactory theme',
|
||||||
'theme.card.avorion': 'Select Avorion theme',
|
'theme.card.avorion': 'Select Avorion theme',
|
||||||
'theme.card.hellion_stealth': 'Select Hellion Stealth theme',
|
'theme.card.hellion_stealth': 'Select Hellion Stealth theme',
|
||||||
|
'theme.card.custom': 'Select custom theme',
|
||||||
|
'theme.builder.title': 'Custom',
|
||||||
|
'theme.builder.accent': 'Accent',
|
||||||
|
'theme.builder.bg': 'Background',
|
||||||
|
'theme.builder.board': 'Board surface',
|
||||||
|
'theme.builder.text': 'Text primary',
|
||||||
|
'theme.builder.text_secondary': 'Text secondary',
|
||||||
|
'theme.builder.text_muted': 'Text muted',
|
||||||
|
'theme.builder.reset': 'Reset',
|
||||||
|
'theme.builder.contrast_good': 'Good contrast',
|
||||||
|
'theme.builder.contrast_ok': 'Borderline',
|
||||||
|
'theme.builder.contrast_bad': 'Hard to read',
|
||||||
'toolbar.label': 'Widget toolbar',
|
'toolbar.label': 'Widget toolbar',
|
||||||
|
|
||||||
// About
|
// About
|
||||||
|
|||||||
@@ -34,6 +34,17 @@
|
|||||||
return 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 }.
|
// Normalisiert eine Bookmark in die kanonische Form { id, title, url, desc }.
|
||||||
// title-Fallback auf url, desc auf ''. Begrenzt Laengen wie data.js (200/500),
|
// title-Fallback auf url, desc auf ''. Begrenzt Laengen wie data.js (200/500),
|
||||||
// damit Quick-Save-Eintraege das gleiche Schema wie Import/Manuell haben.
|
// damit Quick-Save-Eintraege das gleiche Schema wie Import/Manuell haben.
|
||||||
@@ -52,6 +63,7 @@
|
|||||||
|
|
||||||
root.INBOX_ID = INBOX_ID;
|
root.INBOX_ID = INBOX_ID;
|
||||||
root.uid = uid;
|
root.uid = uid;
|
||||||
|
root.isSafeUrl = isSafeUrl;
|
||||||
root.ensureInbox = ensureInbox;
|
root.ensureInbox = ensureInbox;
|
||||||
root.normalizeBookmark = normalizeBookmark;
|
root.normalizeBookmark = normalizeBookmark;
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : self);
|
})(typeof globalThis !== 'undefined' ? globalThis : self);
|
||||||
|
|||||||
+185
-6
@@ -80,6 +80,8 @@ function openThemeModal() {
|
|||||||
document.addEventListener('keydown', _themeTrap);
|
document.addEventListener('keydown', _themeTrap);
|
||||||
const first = _focusable(modal)[0];
|
const first = _focusable(modal)[0];
|
||||||
if (first) first.focus();
|
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');
|
||||||
@@ -113,7 +115,131 @@ function switchTheme(name) {
|
|||||||
*/
|
*/
|
||||||
function isValidBgUrl(url) {
|
function isValidBgUrl(url) {
|
||||||
return typeof url === 'string' && url.length > 0 &&
|
return typeof url === 'string' && url.length > 0 &&
|
||||||
(url.startsWith('blob:') || url.startsWith('data:image/'));
|
(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 ----
|
||||||
@@ -344,7 +470,11 @@ function applySettings() {
|
|||||||
const langEl = document.getElementById('settingLanguage');
|
const langEl = document.getElementById('settingLanguage');
|
||||||
if (langEl) langEl.value = settings.language || 'auto';
|
if (langEl) langEl.value = settings.language || 'auto';
|
||||||
|
|
||||||
|
if (settings.theme === 'custom') {
|
||||||
|
applyCustomTheme(settings.customTheme);
|
||||||
|
} else {
|
||||||
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
|
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
|
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
|
||||||
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
|
||||||
@@ -371,10 +501,25 @@ function bindSettingsEvents() {
|
|||||||
const themeCards = document.querySelectorAll('.theme-card');
|
const themeCards = document.querySelectorAll('.theme-card');
|
||||||
function selectThemeCard(card) {
|
function selectThemeCard(card) {
|
||||||
const name = card.dataset.value;
|
const name = card.dataset.value;
|
||||||
if (!name || name === settings.theme) return Promise.resolve();
|
if (!name) return Promise.resolve();
|
||||||
|
|
||||||
|
// Custom: VOR dem name===settings.theme-Guard, damit ein Re-Klick das Panel wieder oeffnet.
|
||||||
|
if (name === 'custom') {
|
||||||
|
settings.theme = 'custom';
|
||||||
|
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.theme = name;
|
||||||
settings.bgUrl = '';
|
settings.bgUrl = '';
|
||||||
document.getElementById('bgUrlInput').value = '';
|
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
|
// aria-pressed synchron halten — applyTheme/switchTheme pflegt nur die .active-Klasse, nicht ARIA
|
||||||
themeCards.forEach(c => c.setAttribute('aria-pressed', c === card ? 'true' : 'false'));
|
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
|
switchTheme(name); // WICHTIG: switchTheme aus Phase 4 (View-Transition-Wrapper), NICHT applyTheme direkt — sonst geht der Theme-Fade verloren
|
||||||
@@ -390,6 +535,31 @@ function bindSettingsEvents() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
@@ -434,7 +604,9 @@ 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();
|
||||||
@@ -458,8 +630,14 @@ function bindSettingsEvents() {
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async ev => {
|
reader.onload = async ev => {
|
||||||
if (!isValidBgUrl(ev.target.result)) return;
|
if (!isValidBgUrl(ev.target.result)) return;
|
||||||
settings.bgUrl = ev.target.result;
|
let bg = ev.target.result;
|
||||||
document.getElementById('bgLayer').style.backgroundImage = `url('${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 = () => {
|
||||||
@@ -512,7 +690,8 @@ function bindSettingsEvents() {
|
|||||||
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, language: 'auto' };
|
imageRefEnabled: false, language: 'auto', customTheme: null };
|
||||||
|
clearCustomTheme();
|
||||||
await saveBoards();
|
await saveBoards();
|
||||||
await saveTrash();
|
await saveTrash();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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',
|
||||||
@@ -54,6 +55,7 @@ function getDefaultBoards() {
|
|||||||
{ 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 }
|
pos: { x: 40, y: 110 }
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user