Compare commits
288
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d71f868cb2 | ||
|
|
31e4df8bfd | ||
|
|
cacd16ea02 | ||
|
|
08409e61da | ||
|
|
de47cc9c04 | ||
|
|
d10e54d842 | ||
|
|
b08fa1df0a | ||
|
|
ba59752e04 | ||
|
|
aed0169b4b | ||
|
|
4a9d6e1b6b | ||
|
|
f6b0ae2a41 | ||
|
|
7ff428fbba | ||
|
|
52ba546656 | ||
|
|
de1982c0dc | ||
|
|
52133e5d25 | ||
|
|
dfa06d3b8f | ||
|
|
dd9596ef85 | ||
|
|
d6f585ce13 | ||
|
|
e6e2ed70d5 | ||
|
|
9d8fb93175 | ||
|
|
b8478e2029 | ||
|
|
7dd0be2e8a | ||
|
|
76c8647d93 | ||
|
|
9100e018e2 | ||
|
|
bd87f73999 | ||
|
|
56c70877ae | ||
|
|
13c76534f0 | ||
|
|
ebb2849ad7 | ||
|
|
bd6c68dee3 | ||
|
|
f13d046d82 | ||
|
|
315d64b284 | ||
|
|
e434a3d7d0 | ||
|
|
7af6fbf618 | ||
|
|
209d9f4140 | ||
|
|
17df4a82c7 | ||
|
|
a12446a20b | ||
|
|
85f28e54de | ||
|
|
847bb1669c | ||
|
|
3b462ffff4 | ||
|
|
7bc8a81bba | ||
|
|
09a2649c56 | ||
|
|
cb37893366 | ||
|
|
20d238a053 | ||
|
|
4418fae55a | ||
|
|
1fcd953336 | ||
|
|
2d4b5592f3 | ||
|
|
22fca8e23a | ||
|
|
a606847229 | ||
|
|
c9f2d1248d | ||
|
|
725d610875 | ||
|
|
eabbafbebd | ||
|
|
4e73f71617 | ||
|
|
d14f237ffc | ||
|
|
7f9e9e5f82 | ||
|
|
08836bffb4 | ||
|
|
a11fb7a338 | ||
|
|
5813194f43 | ||
|
|
fc7f3329cd | ||
|
|
2ade99f450 | ||
|
|
337e45d18c | ||
|
|
0af3330a01 | ||
|
|
79513ce5af | ||
|
|
7ff7db62a1 | ||
|
|
ea9fb48421 | ||
|
|
58494b7eca | ||
|
|
0e0a6b1fba | ||
|
|
187473b5da | ||
|
|
086b5e994a | ||
|
|
d91a1a0143 | ||
|
|
13eaa0dd7d | ||
|
|
ba1c5e0556 | ||
|
|
1bdf503877 | ||
|
|
edb07b86e2 | ||
|
|
e487c3a172 | ||
|
|
0eaf43575e | ||
|
|
05002e0735 | ||
|
|
968112bac7 | ||
|
|
fee20f830a | ||
|
|
3fc9364db4 | ||
|
|
47c60a950d | ||
|
|
ed3f706e83 | ||
|
|
aca3db12c1 | ||
|
|
e951b1659b | ||
|
|
2342828f24 | ||
|
|
520bbc0268 | ||
|
|
7aa25d4694 | ||
|
|
b76812267b | ||
|
|
9f6883caa1 | ||
|
|
e76c6f98d5 | ||
|
|
a21034a1ac | ||
|
|
53db515649 | ||
|
|
0ed5047833 | ||
|
|
3c9cd1fdaa | ||
|
|
74e8c4d7b2 | ||
|
|
f6299b6b04 | ||
|
|
fac72410c1 | ||
|
|
3aa88ec819 | ||
|
|
ba9cfc91af | ||
|
|
2e9a7a11b3 | ||
|
|
2020861b8b | ||
|
|
e8637954ba | ||
|
|
376de97251 | ||
|
|
67f1cc15c0 | ||
|
|
ce6c09b5b5 | ||
|
|
8b76cd02bb | ||
|
|
7a55767e21 | ||
|
|
0b597b75c8 | ||
|
|
773bdf5606 | ||
|
|
3195047977 | ||
|
|
3208f107ba | ||
|
|
1ce1407ea7 | ||
|
|
e9882719af | ||
|
|
d2fa0d846c | ||
|
|
dc8611ec3a | ||
|
|
abcf23bf0a | ||
|
|
a915fe4b19 | ||
|
|
7c07cd4f89 | ||
|
|
36be085c5b | ||
|
|
0a3bf7b251 | ||
|
|
f9d283004c | ||
|
|
a68f69005e | ||
|
|
f8e7e26517 | ||
|
|
8e302fc925 | ||
|
|
ce59e83d16 | ||
|
|
c34d400a60 | ||
|
|
41bdc76413 | ||
|
|
d7b74e55a2 | ||
|
|
b3673746ae | ||
|
|
32f531707b | ||
|
|
de69c095f0 | ||
|
|
bc5d05207c | ||
|
|
d7819fbd7f | ||
|
|
b621851044 | ||
|
|
ae5c74291c | ||
|
|
7c06eb6a53 | ||
|
|
0d79050525 | ||
|
|
4a35908617 | ||
|
|
bb42622cba | ||
|
|
ad3cdeb744 | ||
|
|
5d2c7dc8df | ||
|
|
50d124ca39 | ||
|
|
e4f39e1d31 | ||
|
|
b62029e2fc | ||
|
|
11e9441d1a | ||
|
|
b2d7335f83 | ||
|
|
ed9f58a57a | ||
|
|
f8944fd139 | ||
|
|
27854cf5f5 | ||
|
|
d56ab6093a | ||
|
|
3fe7fbdfca | ||
|
|
27874d2224 | ||
|
|
e5584d453a | ||
|
|
141443a259 | ||
|
|
e8f791bcdc | ||
|
|
a9f729a411 | ||
|
|
2e9cc62e9b | ||
|
|
1258a832ff | ||
|
|
a4891fac99 | ||
|
|
38fc197d4e | ||
|
|
958c800574 | ||
|
|
c9b532abfb | ||
|
|
cbc9974801 | ||
|
|
6f470879f2 | ||
|
|
80dd3ab65d | ||
|
|
a07173eae6 | ||
|
|
c6e0e280ed | ||
|
|
464b7e1215 | ||
|
|
8b7762a62c | ||
|
|
16334f7c5b | ||
|
|
48a0431777 | ||
|
|
4c88cbd0c8 | ||
|
|
303210ebf6 | ||
|
|
04a09fe4fa | ||
|
|
b98aedd5c7 | ||
|
|
386aee5e56 | ||
|
|
68c992c8ed | ||
|
|
17269ea2c9 | ||
|
|
78bf10fad6 | ||
|
|
b2f8af16d8 | ||
|
|
d9164c34e7 | ||
|
|
61a4f1e93a | ||
|
|
6e8467bcd0 | ||
|
|
a78b57a22e | ||
|
|
3942184ae2 | ||
|
|
74343b1fb9 | ||
|
|
9bb9d3250d | ||
|
|
c868ba80b6 | ||
|
|
8aad710f3e | ||
|
|
84ad3d88a9 | ||
|
|
3c8cc9cf26 | ||
|
|
764e4dc615 | ||
|
|
b4c2216a26 | ||
|
|
6423674897 | ||
|
|
01ff755f53 | ||
|
|
ae160917e3 | ||
|
|
7b7ecfb73d | ||
|
|
83db4676d5 | ||
|
|
fa703b817d | ||
|
|
2163bbbd28 | ||
|
|
62d905b8de | ||
|
|
a2da68c5bf | ||
|
|
22d81327e1 | ||
|
|
08ada03db6 | ||
|
|
9aa9200f70 | ||
|
|
91f6f326cb | ||
|
|
77a6fc432d | ||
|
|
f5108890ef | ||
|
|
b5d7d76d89 | ||
|
|
b6246cd4a5 | ||
|
|
52d3d214ee | ||
|
|
40ae350caf | ||
|
|
8a1e02d8f8 | ||
|
|
e7d7667d0c | ||
|
|
3be28a8e17 | ||
|
|
4816875a65 | ||
|
|
126384f39e | ||
|
|
917fb9f645 | ||
|
|
c62b5ecf37 | ||
|
|
bbbe5d4166 | ||
|
|
db9696b37c | ||
|
|
82cf6a100f | ||
|
|
490581b72d | ||
|
|
26916b79e6 | ||
|
|
e2c630543c | ||
|
|
7af6f7e72b | ||
|
|
99c50c5321 | ||
|
|
99c410a07a | ||
|
|
9aff28f4ad | ||
|
|
e869ddfe1e | ||
|
|
d560723c1c | ||
|
|
43a033bc5b | ||
|
|
e55a832cf4 | ||
|
|
ce0fd7ba06 | ||
|
|
a472b6d475 | ||
|
|
6a1fd08ef3 | ||
|
|
533146c9b6 | ||
|
|
3f22f09521 | ||
|
|
05f1a7bc3f | ||
|
|
eda6720c35 | ||
|
|
fc9850a3a7 | ||
|
|
873d2a2283 | ||
|
|
375e0e3ebc | ||
|
|
d2c03abb10 | ||
|
|
3e516c5799 | ||
|
|
00cee8d2d6 | ||
|
|
b48e03e17b | ||
|
|
d51ea85a82 | ||
|
|
df383644e2 | ||
|
|
83d61e2daf | ||
|
|
4ac62db921 | ||
|
|
61d914ecbb | ||
|
|
2eb759cd49 | ||
|
|
0fde47c9d2 | ||
|
|
56982d9bee | ||
|
|
2c5dcf880e | ||
|
|
17fe7ea369 | ||
|
|
b6411a3453 | ||
|
|
23ab1573dd | ||
|
|
964d9d4b14 | ||
|
|
368f4af822 | ||
|
|
ce34392214 | ||
|
|
bc980703f6 | ||
|
|
84f932ce8b | ||
|
|
2e7d7ce717 | ||
|
|
28f3d85a7a | ||
|
|
c19b00a9b9 | ||
|
|
3252a0afa4 | ||
|
|
d285850844 | ||
|
|
b3756fb4d6 | ||
|
|
6a063e9a18 | ||
|
|
74bc0046ac | ||
|
|
5fedeeced3 | ||
|
|
633fd06ac1 | ||
|
|
1580417f8a | ||
|
|
2cdc409e8d | ||
|
|
398393b1af | ||
|
|
a98f43d407 | ||
|
|
8472e65496 | ||
|
|
d533a66079 | ||
|
|
4a07fd87ff | ||
|
|
34ce7722a0 | ||
|
|
0cbc8b8995 | ||
|
|
685b9786a5 | ||
|
|
f58d2a3e30 | ||
|
|
0a3d0b86e5 | ||
|
|
20f2e9a299 | ||
|
|
637275effc | ||
|
|
40071f718e |
@@ -0,0 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*.{bat,cmd}]
|
||||
charset = latin1
|
||||
end_of_line = crlf
|
||||
insert_final_newline = true
|
||||
@@ -95,6 +95,11 @@ COOLIFY_RESOURCE_UUID=
|
||||
COOLIFY_TRIGGER_MODE=webhook
|
||||
COOLIFY_DEPLOY_METHOD=GET
|
||||
COOLIFY_DEPLOY_ENDPOINT=/api/v1/deploy?uuid={uuid}&force={force}
|
||||
COOLIFY_DEPLOYMENT_STATUS_ENDPOINT=/api/v1/deployments/{deployment_id}
|
||||
VENDOO_DEPLOY_TIMEOUT_SECONDS=900
|
||||
VENDOO_DEPLOY_HEALTH_POLL_SECONDS=15
|
||||
VENDOO_BUILD_REVISION=
|
||||
VENDOO_COOLIFY_AUTO_DEPLOY=false
|
||||
VENDOO_GITHUB_REPOSITORY=Masterluke77/vendoo
|
||||
VENDOO_GITHUB_BRANCH=main
|
||||
VENDOO_GHCR_IMAGE=ghcr.io/masterluke77/vendoo
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -33,6 +34,9 @@ jobs:
|
||||
- name: Abhängigkeiten reproduzierbar installieren
|
||||
run: npm ci
|
||||
|
||||
- name: Navigation API und Safe Actions früh prüfen
|
||||
run: npm run verify:navigation-api && npm run verify:safe-action-adapters
|
||||
|
||||
- name: Git-Sicherheitsgate
|
||||
run: npm run verify:git
|
||||
|
||||
@@ -42,12 +46,21 @@ jobs:
|
||||
- name: Git-Staging-Filter Regressionstest
|
||||
run: npm run verify:git-staging
|
||||
|
||||
- name: Release-Manifest und Paketvertrag prüfen
|
||||
run: npm run verify:release-manifest
|
||||
|
||||
- name: Git-Ignore-Grenzen für Quell- und Runtimeordner prüfen
|
||||
run: npm run verify:ignore-boundaries
|
||||
|
||||
- name: Syntaxprüfung
|
||||
run: npm run check
|
||||
|
||||
- name: PowerShell-Regressionen statisch prüfen
|
||||
run: npm run verify:powershell-static
|
||||
|
||||
- name: Updater-3.4-Lifecycle und Startkette prüfen
|
||||
run: npm run verify:updater-start-chain && npm run verify:updater-lifecycle
|
||||
|
||||
- name: Produktiven Source-Root und Archivgrenzen prüfen
|
||||
run: npm run verify:source-boundaries
|
||||
|
||||
@@ -114,3 +127,135 @@ jobs:
|
||||
|
||||
- name: Docker-Image testweise bauen
|
||||
run: docker build --pull --tag vendoo:ci .
|
||||
|
||||
windows-release-contract:
|
||||
name: Windows – vollständiger Releasevertrag
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Repository auschecken
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Node.js 22 aktivieren
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
|
||||
- name: Abhängigkeiten reproduzierbar installieren
|
||||
run: npm ci
|
||||
|
||||
- name: Navigation API und Safe Actions früh prüfen
|
||||
shell: pwsh
|
||||
run: |
|
||||
& npm.cmd run verify:navigation-api
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
& npm.cmd run verify:safe-action-adapters
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Alle PowerShell-Dateien mit Windows PowerShell 5.1 parsen
|
||||
shell: powershell
|
||||
run: .\tools\verify-powershell-parser.ps1 -ReportPath "$env:RUNNER_TEMP\powershell-parser-diagnostics.txt"
|
||||
|
||||
- name: Parserdiagnose bei Fehler sichern
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: powershell-parser-diagnostics-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: ${{ runner.temp }}/powershell-parser-diagnostics.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Vollständigen Vendoo-Releasevertrag unter Windows ausführen
|
||||
shell: pwsh
|
||||
run: |
|
||||
& npm.cmd run verify:all 2>&1 | Tee-Object -FilePath "$env:RUNNER_TEMP\windows-release-contract.txt"
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) { exit $exitCode }
|
||||
|
||||
- name: Windows-Releasevertrag bei Fehler sichern
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-release-contract-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: ${{ runner.temp }}/windows-release-contract.txt
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
package-after-acceptance:
|
||||
name: Updater-3.4-Paket nach Abnahme
|
||||
needs: [quality, windows-release-contract]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Exakten geprüften Head auschecken
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Node.js 22 aktivieren
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
|
||||
- name: Abhängigkeiten reproduzierbar installieren
|
||||
run: npm ci
|
||||
|
||||
- name: main für den Provenienzvergleich aktualisieren
|
||||
run: git fetch origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Reproduzierbares Root-Manifest bestätigen
|
||||
shell: bash
|
||||
run: |
|
||||
node tools/generate-release-manifest.mjs .
|
||||
git diff --exit-code -- release-manifest.json
|
||||
|
||||
- name: Paket mit unveränderlicher Provenienz erzeugen
|
||||
env:
|
||||
VENDOO_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: node tools/build-release.mjs
|
||||
|
||||
- name: Paketmanifest und SHA-256 prüfen
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_name="$(tr -d '\r\n' < release-output/RELEASE_NAME.txt)"
|
||||
package_dir="release-output/${release_name}"
|
||||
node --input-type=module - "$package_dir" <<'NODE'
|
||||
import { readAndValidateManifest } from './tools/release-manifest-lib.mjs';
|
||||
const packageDir = process.argv[2];
|
||||
const manifest = readAndValidateManifest(packageDir);
|
||||
if (manifest.schema !== 'vendoo-release-package-v2') throw new Error(`Unerwartetes Paketschema: ${manifest.schema}`);
|
||||
if (manifest.updaterVersion !== '3.4.0') throw new Error(`Unerwartete Updater-Version: ${manifest.updaterVersion}`);
|
||||
if (manifest.targetVersion !== '1.43.0') throw new Error(`Unerwartete Zielversion: ${manifest.targetVersion}`);
|
||||
console.log(`Paket validiert: ${manifest.targetVersion}, Updater ${manifest.updaterVersion}, Payload ${manifest.payloadHash}`);
|
||||
NODE
|
||||
archive="release-output/${release_name}.zip"
|
||||
python tools/build-deterministic-zip.py "$package_dir" "$archive"
|
||||
first_hash="$(sha256sum "$archive" | awk '{print $1}')"
|
||||
python tools/build-deterministic-zip.py "$package_dir" "$archive"
|
||||
second_hash="$(sha256sum "$archive" | awk '{print $1}')"
|
||||
if [[ "$first_hash" != "$second_hash" ]]; then
|
||||
echo "Der ZIP-Build ist nicht byte-reproduzierbar." >&2
|
||||
exit 1
|
||||
fi
|
||||
python -m zipfile -t "$archive"
|
||||
(
|
||||
cd release-output
|
||||
sha256sum "${release_name}.zip" > "${release_name}.zip.sha256"
|
||||
sha256sum --check "${release_name}.zip.sha256"
|
||||
)
|
||||
|
||||
- name: Abgenommenes Paket als CI-Artefakt bereitstellen
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vendoo-1.43.0-updater-3.4-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: |
|
||||
release-output/*.zip
|
||||
release-output/*.zip.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
@@ -16,7 +16,20 @@ env:
|
||||
IMAGE_NAME: masterluke77/vendoo
|
||||
|
||||
jobs:
|
||||
powershell-parser:
|
||||
name: Windows PowerShell 5.1 Parser
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Repository auschecken
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Alle PowerShell-Dateien mit Windows PowerShell 5.1 parsen
|
||||
shell: powershell
|
||||
run: .\tools\verify-powershell-parser.ps1
|
||||
|
||||
release-assets:
|
||||
needs: powershell-parser
|
||||
name: Release-Paket und Prüfsummen
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -42,31 +55,21 @@ jobs:
|
||||
EXPECTED="v$(node -p "require('./package.json').version")"
|
||||
test "$RELEASE_TAG" = "$EXPECTED" || { echo "Release-Tag $RELEASE_TAG passt nicht zu $EXPECTED." >&2; exit 1; }
|
||||
|
||||
- name: Vollständige Prüfung
|
||||
run: |
|
||||
npm run verify:git
|
||||
npm run verify:git-regression
|
||||
npm run verify:git-staging
|
||||
npm run verify:ignore-boundaries
|
||||
npm run check
|
||||
npm run verify:source-boundaries
|
||||
npm run verify:esm-exports
|
||||
npm run verify:flux-loop
|
||||
npm run verify:capabilities
|
||||
npm run verify:coolify
|
||||
npm run verify:initial-modules
|
||||
npm run verify:architecture
|
||||
npm run verify:themes
|
||||
npm run verify:security
|
||||
npm run verify:identity
|
||||
npm run verify:catalog
|
||||
npm run verify:modules
|
||||
npm run verify:deployment
|
||||
npm run verify:config
|
||||
npm run verify:installer-shell
|
||||
npm run verify:db
|
||||
- name: ESM-Exportverträge explizit prüfen
|
||||
run: npm run verify:esm-exports
|
||||
|
||||
- name: Release-Staging erzeugen
|
||||
- name: FLUX-Loop-Hotfix explizit prüfen
|
||||
run: npm run verify:flux-loop
|
||||
|
||||
- name: Vollständige Prüfung
|
||||
run: npm run verify:all
|
||||
|
||||
- name: Release-Manifest reproduzierbar prüfen
|
||||
run: |
|
||||
node tools/generate-release-manifest.mjs .
|
||||
git diff --exit-code -- release-manifest.json
|
||||
|
||||
- name: Manifestbasiertes Release-Staging erzeugen
|
||||
run: node tools/build-release.mjs
|
||||
|
||||
- name: ZIP und SHA-256 erzeugen
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Vendoo Release Manifest Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'release/*-production-update-v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: vendoo-release-manifest-sync-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Release-Manifest reproduzierbar synchronisieren
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Release-Branch auschecken
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Node.js 22 aktivieren
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
|
||||
- name: Release-Manifest erzeugen
|
||||
run: node tools/generate-release-manifest.mjs .
|
||||
|
||||
- name: Geändertes Manifest committen
|
||||
id: manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- release-manifest.json; then
|
||||
echo "Release-Manifest ist bereits aktuell."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "vendoo-release-bot"
|
||||
git config user.email "vendoo-release-bot@users.noreply.github.com"
|
||||
git add release-manifest.json
|
||||
git commit -m "chore: Release-Manifest synchronisieren [manifest-sync]"
|
||||
git push origin HEAD:${GITHUB_REF_NAME}
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Finalen Manifest-Head explizit validieren
|
||||
if: ${{ steps.manifest.outputs.changed == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
final_head="${{ steps.manifest.outputs.head_sha }}"
|
||||
remote_head="$(git ls-remote origin "refs/heads/${GITHUB_REF_NAME}" | awk '{print $1}')"
|
||||
if [[ -z "$remote_head" || "$remote_head" != "$final_head" ]]; then
|
||||
echo "Der gepushte Manifest-Head ist nicht stabil: lokal=$final_head remote=$remote_head" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME"
|
||||
gh workflow run sync-release-manifest.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME"
|
||||
echo "Vendoo CI und Manifest-Sync wurden für finalen Head $final_head explizit registriert."
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Vendoo UX Platform Contract
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: vendoo-ux-platform-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ux-platform-contract:
|
||||
name: UX Platform (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Exakten Commit ohne Marketplace-Action auschecken
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||
git fetch --depth=1 origin "${{ github.sha }}"
|
||||
git checkout --detach FETCH_HEAD
|
||||
|
||||
- name: Runner-Node und Plattform dokumentieren
|
||||
run: |
|
||||
node -e "const report=['Platform: '+process.platform,'Node: '+process.version,'Architecture: '+process.arch,'Commit: '+process.env.GITHUB_SHA,''].join('\\n'); process.stdout.write(report);"
|
||||
|
||||
- name: Navigation API und Safe Actions prüfen
|
||||
run: node tools/run-ux-platform-gates.mjs
|
||||
|
||||
- name: Diagnose immer ausgeben
|
||||
if: always()
|
||||
run: node -e "const fs=require('fs'); for (const file of ['artifacts/ux-platform-preflight.log','artifacts/ux-platform-gates.log']) { if (fs.existsSync(file)) { console.log('--- '+file+' ---'); process.stdout.write(fs.readFileSync(file,'utf8')); } }"
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Vendoo Production Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_version:
|
||||
description: Expected Vendoo version
|
||||
required: true
|
||||
type: string
|
||||
public_base_url:
|
||||
description: Public Vendoo URL
|
||||
required: true
|
||||
default: https://vendoo.flatlined.de
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: vendoo-production-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
shell: bash
|
||||
env:
|
||||
TARGET_VERSION: ${{ inputs.target_version }}
|
||||
PUBLIC_BASE_URL: ${{ inputs.public_base_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$TARGET_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
[[ "$PUBLIC_BASE_URL" =~ ^https://[^/]+/?$ ]]
|
||||
|
||||
- name: Trigger Coolify
|
||||
shell: bash
|
||||
env:
|
||||
COOLIFY_DEPLOY_WEBHOOK_URL: ${{ secrets.COOLIFY_DEPLOY_WEBHOOK_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$COOLIFY_DEPLOY_WEBHOOK_URL"
|
||||
curl --fail --silent --show-error --location --request GET \
|
||||
--connect-timeout 20 --max-time 90 \
|
||||
"$COOLIFY_DEPLOY_WEBHOOK_URL" >/tmp/coolify-response.txt
|
||||
echo "Coolify accepted the deployment trigger."
|
||||
|
||||
- name: Wait for exact production version
|
||||
shell: bash
|
||||
env:
|
||||
TARGET_VERSION: ${{ inputs.target_version }}
|
||||
PUBLIC_BASE_URL: ${{ inputs.public_base_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE_URL="${PUBLIC_BASE_URL%/}"
|
||||
deadline=$((SECONDS + 1500))
|
||||
last_version="unknown"
|
||||
while (( SECONDS < deadline )); do
|
||||
if body=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 30 \
|
||||
-H 'Accept: application/json' -H 'Cache-Control: no-cache' \
|
||||
"$BASE_URL/readyz?github_run=${GITHUB_RUN_ID}&attempt=${GITHUB_RUN_ATTEMPT}"); then
|
||||
ready=$(jq -r '.ok // false' <<<"$body")
|
||||
version=$(jq -r '.version // empty' <<<"$body")
|
||||
if [[ -n "$version" ]]; then last_version="$version"; fi
|
||||
echo "ready=$ready version=${version:-unknown} expected=$TARGET_VERSION"
|
||||
if [[ "$ready" == "true" && "$version" == "$TARGET_VERSION" ]]; then exit 0; fi
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
echo "Timeout waiting for Vendoo $TARGET_VERSION. Last version: $last_version" >&2
|
||||
exit 1
|
||||
|
||||
- name: Final health response
|
||||
shell: bash
|
||||
env:
|
||||
PUBLIC_BASE_URL: ${{ inputs.public_base_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE_URL="${PUBLIC_BASE_URL%/}"
|
||||
curl --fail --silent --show-error -H 'Accept: application/json' \
|
||||
"$BASE_URL/healthz?github_run=${GITHUB_RUN_ID}" | jq .
|
||||
@@ -0,0 +1,125 @@
|
||||
name: Vendoo Windows Release Diagnostics
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: vendoo-windows-diagnostics-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
windows-release-diagnostics:
|
||||
name: Windows – Release-Gates einzeln
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Repository auschecken
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Node.js 22 aktivieren
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
|
||||
- name: Abhängigkeiten reproduzierbar installieren
|
||||
run: npm ci
|
||||
|
||||
- name: Alle PowerShell-Dateien mit Windows PowerShell 5.1 parsen
|
||||
shell: powershell
|
||||
run: .\tools\verify-powershell-parser.ps1
|
||||
|
||||
- name: Git-Sicherheitsgate
|
||||
run: npm run verify:git
|
||||
|
||||
- name: Git-Sicherheitsgate Regressionstest
|
||||
run: npm run verify:git-regression
|
||||
|
||||
- name: Git-Staging-Filter Regressionstest
|
||||
run: npm run verify:git-staging
|
||||
|
||||
- name: Release-Manifest prüfen
|
||||
run: npm run verify:release-manifest
|
||||
|
||||
- name: Git-Ignore-Grenzen prüfen
|
||||
run: npm run verify:ignore-boundaries
|
||||
|
||||
- name: Syntax und Identity-Datenbank prüfen
|
||||
run: npm run check
|
||||
|
||||
- name: PowerShell-Regressionen statisch prüfen
|
||||
run: npm run verify:powershell-static
|
||||
|
||||
- name: Source-Root-Grenzen prüfen
|
||||
run: npm run verify:source-boundaries
|
||||
|
||||
- name: ESM-Exportverträge prüfen
|
||||
run: npm run verify:esm-exports
|
||||
|
||||
- name: FLUX-Loop-Hotfix prüfen
|
||||
run: npm run verify:flux-loop
|
||||
|
||||
- name: Capability-Graph prüfen
|
||||
run: npm run verify:capabilities
|
||||
|
||||
- name: Coolify- und Produktionsoperationen prüfen
|
||||
run: npm run verify:coolify
|
||||
|
||||
- name: Initial deaktivierte Module prüfen
|
||||
run: npm run verify:initial-modules
|
||||
|
||||
- name: Plattformarchitektur prüfen
|
||||
run: npm run verify:architecture
|
||||
|
||||
- name: Theme Manager prüfen
|
||||
run: npm run verify:themes
|
||||
|
||||
- name: Security Foundation prüfen
|
||||
run: npm run verify:security
|
||||
|
||||
- name: Identity und Settings prüfen
|
||||
run: npm run verify:identity
|
||||
|
||||
- name: Einladungen prüfen
|
||||
run: npm run verify:invitations
|
||||
|
||||
- name: SQLite-Cleanup unter Windows prüfen
|
||||
run: npm run verify:sqlite-cleanup
|
||||
|
||||
- name: Katalog und Medien prüfen
|
||||
run: npm run verify:catalog
|
||||
|
||||
- name: Modulmanager prüfen
|
||||
run: npm run verify:modules
|
||||
|
||||
- name: Produktionsoperationen prüfen
|
||||
run: npm run verify:production-operations
|
||||
|
||||
- name: One-Click-Updater prüfen
|
||||
run: npm run verify:one-click-update
|
||||
|
||||
- name: Modulnavigation prüfen
|
||||
run: npm run verify:module-navigation
|
||||
|
||||
- name: Updater-UI prüfen
|
||||
run: npm run verify:updater-ui
|
||||
|
||||
- name: Updater-Hotfix-Vertrag prüfen
|
||||
run: npm run verify:updater-hotfix
|
||||
|
||||
- name: Deployment prüfen
|
||||
run: npm run verify:deployment
|
||||
|
||||
- name: Konfigurationsspeicher prüfen
|
||||
run: npm run verify:config
|
||||
|
||||
- name: Installer-Shell-Portabilität prüfen
|
||||
run: npm run verify:installer-shell
|
||||
|
||||
- name: Datenbankmigrationen prüfen
|
||||
run: npm run verify:db
|
||||
@@ -0,0 +1,36 @@
|
||||
# Vendoo 1.43.0 – Start hier
|
||||
|
||||
## Production Updater 3.3 starten
|
||||
|
||||
1. Dieses ZIP vollständig in einen neuen lokalen Ordner entpacken, zum Beispiel `C:\Vendoo-Update`.
|
||||
2. Im Ordner `vendoo` doppelt auf `UPDATE_VENDOO.bat` klicken.
|
||||
3. Den Vendoo Production Updater bis zum eindeutigen Ergebnis geöffnet lassen.
|
||||
|
||||
Der Updater zeigt zehn klare Phasen, Fortschritt, Live-Protokoll und einen dauerhaft sichtbaren Fehlerzustand. Beim ersten Lauf kann einmalig die GitHub-Anmeldung im Browser erforderlich sein. GitHub CLI wird bei Bedarf als verwaltete portable Kopie geladen und per SHA-256 geprüft.
|
||||
|
||||
Ein Coolify-Webhook wird nicht abgefragt. Nach einem erfolgreichen Merge verwendet Vendoo das bereits eingerichtete Coolify Auto Deploy der GitHub-Verknüpfung und bestätigt anschließend `/readyz` mit exakt Version `1.43.0`.
|
||||
|
||||
## Release-Architektur 3.0
|
||||
|
||||
- Die geklonte `main`-Arbeitskopie wird nicht geleert.
|
||||
- `release-manifest.json` enthält jede auszuliefernde Datei mit Größe und SHA-256.
|
||||
- Pflichtdateien wie `db/schema.sql` werden vor den Tests hart geprüft.
|
||||
- Dateien werden als Overlay angewendet und im Ziel erneut gehasht.
|
||||
- `release-manifest.json` wird anschließend als bytegeprüfte Steuerdatei in die Arbeitskopie übernommen.
|
||||
- Löschungen sind nur über die explizite Liste `removedFiles` erlaubt.
|
||||
- Alte Updater-2.x-Sitzungen werden nicht übernommen.
|
||||
- GitHub Actions führt den vollständigen Releasevertrag auf Windows und Linux aus.
|
||||
|
||||
## Inhalt von Vendoo 1.43.0
|
||||
|
||||
- modulabhängige Navigation: deaktivierte Module verschwinden sofort aus der Seitenleiste,
|
||||
- optionales, standardmäßig deaktiviertes Modul **Produktbild Studio**,
|
||||
- grundlegend überarbeitete Seite **Neuer Artikel**,
|
||||
- dauerhaft sichtbare Aktionen **Artikel generieren** und **Speichern**,
|
||||
- manifestbasierter Production Updater 3.3.
|
||||
|
||||
## Sicherheitsgrenzen
|
||||
|
||||
Der Updater ersetzt oder löscht niemals `/app/data`, Datenbanken, Uploads, Backups, `.env` oder API-Schlüssel. GitHub-Checks, kontrollierter Merge, Coolify Auto Deploy und `/readyz`-Versionskontrolle bleiben verpflichtend.
|
||||
|
||||
Technische Details: `docs/PRODUCTION_UPDATER_3_3.md`.
|
||||
@@ -1,5 +1,95 @@
|
||||
## Vendoo Production Updater 3.3 – Manifest Control Artifact Contract
|
||||
|
||||
- `release-manifest.json` wird nach dem Payload-Overlay ausdrücklich in die Git-Arbeitskopie kopiert.
|
||||
- Die Steuerdatei wird bytegenau gegen das Release-Paket geprüft.
|
||||
- Manifestprüfung in einer Git-Baseline toleriert zusätzliche unveränderte Baseline-Dateien, prüft aber alle manifestierten Dateien vollständig.
|
||||
- `removedFiles` darf die Manifest-Steuerdatei nicht löschen.
|
||||
- Der Windows-Lauf kann dadurch `verify-production-updater-3.3.mjs` innerhalb der geklonten Arbeitskopie ausführen.
|
||||
- Prozessfehler zeigen in Status und Oberfläche zuerst die echte Fehlerausgabe; lange Standardausgaben verdrängen die Ursache nicht mehr.
|
||||
|
||||
## Vendoo Production Updater 3.1 – Manifest-Based Release Application
|
||||
|
||||
- replaces destructive clone cleanup and exclusion-list copying with SHA-256 verified overlay manifest
|
||||
- requires `db/schema.sql` and all release-critical source files before tests can start
|
||||
- preserves baseline files unless deletion is explicitly declared in `removedFiles`
|
||||
- verifies every payload file before and after copying
|
||||
- rejects stale Updater-2.x sessions and uses a dedicated v3 release branch
|
||||
|
||||
## Vendoo Production Updater 2.10 – Windows Installer-Shell Portability
|
||||
|
||||
- `verify:installer-shell` läuft über einen plattformneutralen Node-Wrapper.
|
||||
- Unter Windows wird `sh.exe` aus Git for Windows über `where git`, `git --exec-path` und bekannte Installationspfade ermittelt.
|
||||
- Keine globale `sh`-PATH-Annahme mehr.
|
||||
- Git-Fortschritt auf stderr wird bei Exit-Code 0 als Ausgabe statt als Fehler protokolliert; npm-Warnungen erscheinen als Warnung.
|
||||
- Zielversion der Anwendung bleibt Vendoo 1.43.0.
|
||||
|
||||
## Vendoo Production Updater 2.9 – Systematic Windows SQLite Handle Audit
|
||||
|
||||
- Zentraler Cleanup für temporäre Vendoo-SQLite-Datenbanken.
|
||||
- Einladungs-Verifier schließt `better-sqlite3` vor dem Entfernen des Temp-Ordners.
|
||||
- Aktive Verifier werden transitiv auf offene `lib/db.mjs`-Handles geprüft.
|
||||
- Windows-Cleanup-Regression läuft für Initialmodule und Einladungen jeweils dreifach isoliert.
|
||||
|
||||
## Vendoo Production Updater 2.8 – Windows SQLite Cleanup
|
||||
|
||||
- `verify:initial-modules` schließt die temporäre `better-sqlite3`-Datenbank jetzt vor dem Entfernen des Testordners.
|
||||
- Windows-`EBUSY` beim Löschen von `vendoo.db` behoben.
|
||||
- Neuer dreifacher Cleanup-Regressionstest verhindert offene SQLite-Dateien und Temp-Reste.
|
||||
|
||||
# Vendoo 1.43.0 – Production Updater 2.4
|
||||
|
||||
- GitHub-Browseranmeldung aus dem versteckten Worker entfernt.
|
||||
- Sichtbares, klar beschriftetes Geräte-Anmeldefenster mit `--web --clipboard`.
|
||||
- Stilles Loginstatus-Polling und automatische Fortsetzung nach erfolgreicher Anmeldung.
|
||||
- Downloadfortschritt auf 10-%-Stufen gedrosselt; keine Logflut mehr.
|
||||
- Neues Updater-2.4-Regressionsgate.
|
||||
|
||||
# Changelog
|
||||
|
||||
## Updater 2.1 – Worker Host & Bootstrap Diagnostics
|
||||
|
||||
- überwachten Worker-Host eingeführt
|
||||
- Status und Protokollpfad vor Worker-Start garantiert
|
||||
- Standardausgabe und Standardfehler vollständig erfasst
|
||||
- Lade-, Parser- und Initialisierungsfehler mit Datei und Zeile sichtbar
|
||||
- Diagnoseordner direkt in der Oberfläche erreichbar
|
||||
- Worker-Core beendet den Host nicht mehr eigenmächtig
|
||||
|
||||
## 1.43.0 Updater Revision 2.0 – Production Updater Replacement
|
||||
|
||||
- Bisherige Winget-/Konsolen-Hotfix-Kette vollständig durch einen eigenständigen WPF-Production-Updater ersetzt.
|
||||
- Neutrale dunkle Oberfläche mit zehn klaren Updatephasen, Fortschritt, Laufzeit, Heartbeat und Live-Protokoll.
|
||||
- Fehlerzustände bleiben dauerhaft sichtbar; das Fenster schließt sich weder bei Fehlern noch bei einem unerwarteten Worker-Ende automatisch.
|
||||
- Aktionen für Protokoll öffnen, Fehler kopieren, kontrollierten Abbruch und Wiederaufnahme.
|
||||
- Verwaltete portable GitHub CLI mit offiziellem Release-Download und SHA-256-Prüfung statt Winget-Abhängigkeit.
|
||||
- Feste Timeouts, Prozessbaum-Abbruch und Heartbeats für alle externen Werkzeuge.
|
||||
- Persistente Sitzung für sichere Wiederaufnahme nach Abbruch oder Neustart.
|
||||
- GitHub-, Test-, Merge-, Coolify- und Produktionsprüfung bleiben als getrennte Phasen nachvollziehbar.
|
||||
|
||||
## 1.43.0 – Module-aware Navigation, Listing Studio UX & Updater Status UI
|
||||
|
||||
- Navigationseinträge verschwinden unmittelbar, wenn das zugehörige Modul deaktiviert wird.
|
||||
- Neues authentifiziertes, minimales Modulstatus-API für die Oberfläche.
|
||||
- Produktbild Studio als eigenständiges, standardmäßig deaktiviertes Modul.
|
||||
- Seite „Neuer Artikel“ neu geordnet, lesbarer und konsequent am Theme-System ausgerichtet.
|
||||
- „Artikel generieren“ und „Speichern“ bleiben in einer sticky Aktionsleiste dauerhaft erreichbar.
|
||||
- Ein-Klick-Updater mit grafischer Fortschrittsanzeige, Prozentwert, Phase und Live-Protokoll.
|
||||
|
||||
## 1.42.0 – Production Operations & Controlled Update Completion
|
||||
|
||||
- Persistente Deployment-State-Machine mit Ereignisverlauf und Wiederaufnahme nach Neustart.
|
||||
- Idempotency-Key und Sperre gegen parallele Deployment-Aufträge.
|
||||
- Vorab-Backup wird erstellt, verifiziert und mit dem Auftrag verknüpft.
|
||||
- Coolify-Trigger, Remote-Status, Readiness und Zielversion werden getrennt bewertet.
|
||||
- Webhook-Abschlüsse benötigen eine zusätzliche exakte Bestätigung `CONFIRM`.
|
||||
- Fehler und Zeitüberschreitungen wechseln in `rollback_required` und blockieren neue Aufträge bis zur manuellen Prüfung.
|
||||
- Neuer Produktionscheck und Produktionsstatus im Update Center.
|
||||
- Additive Tabellen `deployment_runs`, `deployment_events`, `backup_verifications` und `production_checks`.
|
||||
- Keine Löschung oder Rücksetzung bestehender Daten, Benutzer, Uploads, Secrets, Backups oder Module.
|
||||
- Neuer `UPDATE_VENDOO.bat`-Assistent automatisiert Voraussetzungen, Volltest, Release-Branch, Pull Request, Merge und Coolify-Deployment.
|
||||
- Coolify-Webhook wird nur einmalig als verschlüsseltes GitHub Actions Secret hinterlegt.
|
||||
- Produktionsworkflow meldet Erfolg ausschließlich bei `/readyz` und exakt Version `1.42.0`.
|
||||
|
||||
## 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix
|
||||
|
||||
- Root-Runtimeordner in `.gitignore` sind jetzt mit `/` verankert.
|
||||
|
||||
+23
@@ -1071,3 +1071,26 @@ Neue Kernel-Routen benötigen zwingend einen Modulbesitzer und eine explizite Po
|
||||
- `app/modules/` und `app/modules/sessions/` bleiben versionierter Source
|
||||
- Git-Ignore-Semantiktest mit echtem temporärem Repository
|
||||
- Pflichtdatei- und Tracking-Gate im GitHub-Deploy-Assistenten
|
||||
|
||||
|
||||
## 1.42.0 – Production Operations & Controlled Update Completion
|
||||
|
||||
- Persistente Deployment-State-Machine: `checking`, `backup_pending`, `backup_running`, `backup_verified`, `deploy_requested`, `deploy_running`, `health_wait`, `succeeded`, `failed`, `rollback_required`.
|
||||
- Idempotente Aufträge und harter Doppelstartschutz.
|
||||
- Verifiziertes Vorab-Backup mit SHA-256-/Manifestbezug.
|
||||
- Coolify-API-Statusabfrage über konfigurierbaren relativen Endpunkt; Webhook-Modus mit expliziter Abschlussbestätigung.
|
||||
- Readiness- und Zielversionsprüfung vor Erfolgsmeldung.
|
||||
- Persistenter Ereignisverlauf, Neustart-Wiederaufnahme und kontrollierte Aufhebung einer Rollback-Sperre.
|
||||
- Produktionsstatus und Produktionscheck im Update Center.
|
||||
- Additive Migration ohne Reset bestehender Produktivdaten.
|
||||
|
||||
|
||||
## 1.43.0 – Module-aware Navigation, Listing Studio & Updater Status UI
|
||||
|
||||
- Authentifizierter Modulstatus-Endpunkt `/api/modules/navigation` mit minimaler Antwortfläche.
|
||||
- Navigationselemente und optionale UI-Panels sind über `data-module` beziehungsweise `data-module-panel` an den realen Modulstatus gekoppelt.
|
||||
- Modulstatus wird vor geschützten Modul-APIs geladen; ein deaktiviertes AI-Modul blockiert den App-Start nicht.
|
||||
- Neuer Manifestvertrag `defaultEnabled`, damit ein Modul standardmäßig aus, aber administrativ aktivierbar sein kann.
|
||||
- `vendoo.product-image-tools` stellt `product-images.variants` bereit und startet standardmäßig deaktiviert.
|
||||
- Listing Studio mit sticky Command Bar, klarer Zwei-Spalten-Arbeitsfläche und dauerhaft sichtbaren Aktionen.
|
||||
- Grafische Update-Oberfläche mit Fortschritt, Phase, Detailmeldung, Live-Protokoll, sicherer einmaliger Coolify-Eingabe und korrektem Exit-Code.
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
chcp 65001 >nul
|
||||
title Vendoo GitHub Deploy Assistent
|
||||
cd /d "%~dp0"
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -STA -File "%~dp0scripts\github-deploy.ps1"
|
||||
set "EXITCODE=%ERRORLEVEL%"
|
||||
if not "%EXITCODE%"=="0" (
|
||||
echo.
|
||||
echo [FEHLER] GitHub-Deployment wurde mit Exit-Code %EXITCODE% beendet.
|
||||
echo.
|
||||
pause
|
||||
)
|
||||
exit /b %EXITCODE%
|
||||
rem Alter Dateiname bleibt als Kompatibilitaetsstarter erhalten.
|
||||
call "%~dp0UPDATE_VENDOO.bat"
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
# Vendoo 1.41.2
|
||||
# Vendoo 1.43.0
|
||||
|
||||
Vendoo 1.41.1 ist die korrigierte Produktionsgrundlage für den Betrieb über **Coolify**, GitHub und `https://vendoo.flatlined.de`. Der Container bleibt unveränderlich: Vendoo führt weder `git pull` noch Docker-Befehle aus, sondern delegiert freigegebene Deployments nach einem verifizierten Vorab-Backup an Coolify.
|
||||
Vendoo 1.43.0 erweitert die kontrollierte Produktionsgrundlage um modulabhängige Navigation, ein optionales Produktbild Studio, ein neu aufgebautes Listing Studio und einen grafischen Ein-Klick-Updater. Der Container bleibt unveränderlich: Vendoo führt weder `git pull` noch Docker-Befehle aus, sondern delegiert freigegebene Deployments nach einem verifizierten Vorab-Backup an Coolify.
|
||||
|
||||
|
||||
## Release 1.43.0: Module-aware Navigation, Listing Studio & Updater UI
|
||||
|
||||
- deaktivierte Fachmodule verschwinden unmittelbar aus der linken Navigation
|
||||
- Modulstatus wird vor modulgeschützten APIs geladen, damit deaktivierte Module den App-Start nicht blockieren
|
||||
- Produktbild Studio als eigenes natives Modul mit `defaultEnabled: false`
|
||||
- überarbeitete Seite **Neuer Artikel** mit theme-konformen Flächen, Abständen und responsivem Arbeitsbereich
|
||||
- sticky Aktionsleiste: **Artikel generieren** und **Speichern** bleiben oben erreichbar
|
||||
- grafischer `UPDATE_VENDOO.bat`-Ablauf mit Phase, Detailtext, Prozentfortschritt, Live-Protokoll und sicherer Webhook-Eingabe
|
||||
|
||||
## Release 1.42.0: Production Operations & Controlled Update Completion
|
||||
|
||||
- persistente Deployment-Aufträge und Ereignisse in SQLite
|
||||
- idempotente Auslösung und Sperre gegen parallele Deployments
|
||||
- verifiziertes Vorab-Backup als fester Bestandteil des Auftrags
|
||||
- getrennte Zustände für Coolify-Annahme, laufendes Deployment, Readiness, Erfolg, Fehler und Rollbackbedarf
|
||||
- Erfolg erst nach Readiness und bestätigter Zielversion beziehungsweise expliziter externer Bestätigung
|
||||
- Produktionscheck für HTTPS, Cookies, Proxy, Hosts/Origins, persistente Pfade, SQLite, Backupziel, FLUX und Coolify-Konfiguration
|
||||
- kein Docker-Socket, kein `git pull`, kein `npm install` und keine Selbstüberschreibung im Produktivcontainer
|
||||
|
||||
## Hotfix 1.41.2: native Module zuverlässig in Git
|
||||
|
||||
@@ -24,16 +44,16 @@ Runtimeordner werden in `.gitignore` ausschließlich am Projektroot ausgeschloss
|
||||
- keine Weitergabe des Docker-Sockets an Vendoo
|
||||
- genaue Anleitung: `docs/COOLIFY_PRODUCTION_1_41_0.md`
|
||||
|
||||
## Kontrollierte Web-Updates
|
||||
## Kontrollierte Updates
|
||||
|
||||
- geschütztes natives Modul `vendoo.deployments`
|
||||
- Coolify-Deploy-Webhook oder minimal berechtigter API-Token
|
||||
- Coolify-Zugangsdaten AES-256-GCM-verschlüsselt im Secret Store
|
||||
- exakte Administratorbestätigung `DEPLOY`
|
||||
- verpflichtendes Vorab-Backup vor dem Deploymentauftrag
|
||||
- GitHub-Repository, Produktionsbranch und Update-Kanal im Update Center
|
||||
- kein selbstverändernder Container, kein `git pull`, kein Docker-Socket
|
||||
- vollständiger Ablauf: `docs/WEB_UPDATE_CENTER_1_41_0.md`
|
||||
- Production Updater 3.3 mit SHA-256-geprüftem Payload und bytegeprüfter Manifest-Steuerdatei
|
||||
- keine destruktive Leerung der geklonten Git-Baseline
|
||||
- Pflichtdateien wie `db/schema.sql` werden vor Tests und Push geprüft
|
||||
- Pull Request und vollständige GitHub-Checks auf Windows und Linux
|
||||
- Coolify Auto Deploy nach erfolgreichem Merge nach `main`
|
||||
- exakte Produktionsbestätigung über `/readyz` und Zielversion
|
||||
- kein selbstverändernder Container, kein `git pull` in Produktion, kein Docker-Socket
|
||||
- technische Details: `docs/PRODUCTION_UPDATER_3_1.md`
|
||||
|
||||
## GitHub-, Merge- und Releaseprozess
|
||||
|
||||
@@ -47,10 +67,10 @@ Runtimeordner werden in `.gitignore` ausschließlich am Projektroot ausgeschloss
|
||||
|
||||
## Plattformstand
|
||||
|
||||
- 17 registrierte Module
|
||||
- 15 native Module
|
||||
- 18 registrierte Module
|
||||
- 16 native Module, davon Produktbild Studio standardmäßig deaktiviert
|
||||
- 2 transparente Legacy Bridges: System und Operations
|
||||
- 38 vollständig aufgelöste Capabilities
|
||||
- 41 vollständig aufgelöste Capabilities
|
||||
- geschützte Kernmodule und kontrollierbare optionale Module
|
||||
- `.vmod`-Import/Export, Quarantäne und Abhängigkeitssteuerung
|
||||
- Theme-, Security-, Identity-, Catalog-, Media-, Publishing-, AI-, FLUX- und Quality-Module bleiben enthalten
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
|
||||
cd /d "%~dp0"
|
||||
if errorlevel 1 goto :path_error
|
||||
|
||||
set "VENDOO_PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
if not exist "%VENDOO_PS%" goto :powershell_missing
|
||||
|
||||
echo Vendoo Production Updater 3.4 preflight...
|
||||
"%VENDOO_PS%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File ".\scripts\updater\Vendoo.Updater.Preflight.ps1"
|
||||
set "VENDOO_RC=%ERRORLEVEL%"
|
||||
if not "%VENDOO_RC%"=="0" goto :preflight_failed
|
||||
|
||||
"%VENDOO_PS%" -NoLogo -NoProfile -ExecutionPolicy Bypass -STA -File ".\scripts\update-vendoo-gui.ps1"
|
||||
set "VENDOO_RC=%ERRORLEVEL%"
|
||||
if not "%VENDOO_RC%"=="0" goto :gui_failed
|
||||
|
||||
exit /b 0
|
||||
|
||||
:path_error
|
||||
echo.
|
||||
echo [ERROR] The Vendoo package directory could not be opened.
|
||||
echo Path: %~dp0
|
||||
echo.
|
||||
pause
|
||||
exit /b 30
|
||||
|
||||
:powershell_missing
|
||||
echo.
|
||||
echo [ERROR] Windows PowerShell 5.1 was not found.
|
||||
echo Expected: %VENDOO_PS%
|
||||
echo.
|
||||
pause
|
||||
exit /b 31
|
||||
|
||||
:preflight_failed
|
||||
echo.
|
||||
echo [ERROR] The Windows PowerShell preflight failed with exit code %VENDOO_RC%.
|
||||
echo Log: %CD%\logs\updater-preflight.log
|
||||
echo.
|
||||
pause
|
||||
exit /b %VENDOO_RC%
|
||||
|
||||
:gui_failed
|
||||
echo.
|
||||
echo [ERROR] The updater UI exited with code %VENDOO_RC%.
|
||||
echo Log: %CD%\logs\updater-bootstrap.log
|
||||
echo.
|
||||
pause
|
||||
exit /b %VENDOO_RC%
|
||||
@@ -4,31 +4,91 @@
|
||||
"title": "Vendoo Module Manifest",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schemaVersion", "id", "name", "version", "type", "status", "requires", "permissions", "provides", "consumes", "owns"],
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"name",
|
||||
"version",
|
||||
"type",
|
||||
"status",
|
||||
"requires",
|
||||
"permissions",
|
||||
"provides",
|
||||
"consumes",
|
||||
"owns"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": 1 },
|
||||
"id": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" },
|
||||
"type": { "enum": ["core", "feature", "adapter", "extension-host"] },
|
||||
"status": { "enum": ["native", "legacy-bridge", "disabled"] },
|
||||
"description": { "type": "string", "maxLength": 500 },
|
||||
"requires": { "$ref": "#/$defs/moduleIds" },
|
||||
"permissions": { "$ref": "#/$defs/capabilities" },
|
||||
"provides": { "$ref": "#/$defs/capabilities" },
|
||||
"consumes": { "$ref": "#/$defs/capabilities" },
|
||||
"owns": { "$ref": "#/$defs/capabilities" }
|
||||
"schemaVersion": {
|
||||
"const": 1
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^vendoo\\.[a-z][a-z0-9-]*$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"core",
|
||||
"feature",
|
||||
"adapter",
|
||||
"extension-host"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"native",
|
||||
"legacy-bridge",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"defaultEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 500
|
||||
},
|
||||
"requires": {
|
||||
"$ref": "#/$defs/moduleIds"
|
||||
},
|
||||
"permissions": {
|
||||
"$ref": "#/$defs/capabilities"
|
||||
},
|
||||
"provides": {
|
||||
"$ref": "#/$defs/capabilities"
|
||||
},
|
||||
"consumes": {
|
||||
"$ref": "#/$defs/capabilities"
|
||||
},
|
||||
"owns": {
|
||||
"$ref": "#/$defs/capabilities"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"moduleIds": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^vendoo\\.[a-z][a-z0-9-]*$"
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +296,38 @@ export function validateAuthToken(token) {
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
export function listInvitations(limit = 100) {
|
||||
const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100));
|
||||
return db.prepare(`SELECT id, email, role, used, expires_at, created_by, created_at,
|
||||
CASE
|
||||
WHEN used = 1 THEN 'revoked_or_used'
|
||||
WHEN expires_at <= datetime('now') THEN 'expired'
|
||||
ELSE 'pending'
|
||||
END AS status
|
||||
FROM auth_tokens
|
||||
WHERE type = 'invite'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`).all(safeLimit);
|
||||
}
|
||||
|
||||
export function getInvitation(id) {
|
||||
return db.prepare(`SELECT id, email, role, used, expires_at, created_by, created_at,
|
||||
CASE
|
||||
WHEN used = 1 THEN 'revoked_or_used'
|
||||
WHEN expires_at <= datetime('now') THEN 'expired'
|
||||
ELSE 'pending'
|
||||
END AS status
|
||||
FROM auth_tokens WHERE id = ? AND type = 'invite'`).get(Number(id));
|
||||
}
|
||||
|
||||
export function revokeInvitation(id) {
|
||||
const invitation = getInvitation(id);
|
||||
if (!invitation) return null;
|
||||
if (invitation.status === 'pending') db.prepare('UPDATE auth_tokens SET used = 1 WHERE id = ? AND type = ?').run(Number(id), 'invite');
|
||||
return { ...invitation, status: invitation.status === 'pending' ? 'revoked' : invitation.status, used: 1 };
|
||||
}
|
||||
|
||||
// --- Rate Limiting ---
|
||||
const rateLimits = new Map();
|
||||
|
||||
|
||||
@@ -163,7 +163,11 @@ export function buildModulePackage({ manifest, runtime = { mode: 'builtin-refere
|
||||
const buffer = Buffer.isBuffer(file.content) ? file.content : Buffer.from(String(file.content || ''), 'utf8');
|
||||
return { path, sha256: sha256(buffer), size: buffer.length, content: buffer.toString('base64') };
|
||||
});
|
||||
const draft = { format: MODULE_PACKAGE_FORMAT, schemaVersion: MODULE_PACKAGE_SCHEMA, manifest, runtime, files: normalizedFiles, createdAt, integrity: { algorithm: 'sha256', digest: '' }, signature: null };
|
||||
// Der Digest muss auf demselben normalisierten Manifest basieren wie die spätere
|
||||
// Validierung. Sonst würden additive Manifest-Defaults (z. B. defaultEnabled)
|
||||
// den Digest nachträglich verändern.
|
||||
const normalizedManifest = validateModuleManifest(manifest);
|
||||
const draft = { format: MODULE_PACKAGE_FORMAT, schemaVersion: MODULE_PACKAGE_SCHEMA, manifest: normalizedManifest, runtime, files: normalizedFiles, createdAt, integrity: { algorithm: 'sha256', digest: '' }, signature: null };
|
||||
draft.integrity.digest = calculatePackageDigest(draft);
|
||||
return validateModulePackage(draft, { allowBuiltinReference: true });
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function validateModuleManifest(manifest) {
|
||||
version: String(manifest.version || '').trim(),
|
||||
type: String(manifest.type || '').trim(),
|
||||
status: String(manifest.status || 'native').trim(),
|
||||
defaultEnabled: manifest.defaultEnabled !== false,
|
||||
description: String(manifest.description || '').trim(),
|
||||
requires: Object.freeze(uniqueStrings(manifest.requires || [], 'requires', MODULE_ID)),
|
||||
permissions: Object.freeze(uniqueStrings(manifest.permissions || [], 'permissions')),
|
||||
|
||||
@@ -84,6 +84,7 @@ export function createPlatformKernel({ version, hasPermission, moduleTrustKeys =
|
||||
features.define('modules.package-format', { defaultValue: true, owner: 'vendoo.module-manager', description: 'Aktiviert validierte .vmod-Pakete mit SHA-256-Integrität und optionaler Ed25519-Signatur.' });
|
||||
features.define('publishing.native-module', { defaultValue: true, owner: 'vendoo.publishing', description: 'Aktiviert native Publishing-Verträge.' });
|
||||
features.define('ai.native-module', { defaultValue: true, owner: 'vendoo.ai', description: 'Aktiviert native AI-Verträge.' });
|
||||
features.define('product-images.optional-module', { defaultValue: false, owner: 'vendoo.product-image-tools', description: 'Aktiviert optionale Ghost-Mannequin- und Flatlay-Produktbildvarianten.' });
|
||||
features.define('flux.native-module', { defaultValue: true, owner: 'vendoo.flux-studio', description: 'Aktiviert native FLUX-Verträge.' });
|
||||
features.define('quality.native-module', { defaultValue: true, owner: 'vendoo.quality', description: 'Aktiviert native Quality-Verträge.' });
|
||||
features.define('deployments.coolify', { defaultValue: true, owner: 'vendoo.deployments', description: 'Aktiviert kontrollierte Coolify-Deployments ohne Docker-Socket.' });
|
||||
@@ -93,7 +94,7 @@ export function createPlatformKernel({ version, hasPermission, moduleTrustKeys =
|
||||
.split(',').map(value => value.trim()).filter(Boolean));
|
||||
for (const module of loadBuiltInModules()) {
|
||||
const forcedEnabled = PROTECTED_MODULE_IDS.includes(module.manifest.id);
|
||||
const defaultEnabled = module.manifest.status !== 'disabled' && !initialDisabledModules.has(module.manifest.id);
|
||||
const defaultEnabled = module.manifest.status !== 'disabled' && module.manifest.defaultEnabled !== false && !initialDisabledModules.has(module.manifest.id);
|
||||
const enabled = forcedEnabled || moduleStateStore.isEnabled(module.manifest.id, defaultEnabled);
|
||||
modules.register(module, { enabled });
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createUsersModule } from './users/index.mjs';
|
||||
import { createModuleManagerModule } from './module-manager/index.mjs';
|
||||
import { createAiModule } from './ai/index.mjs';
|
||||
import { createFluxStudioModule } from './flux-studio/index.mjs';
|
||||
import { createProductImageToolsModule } from './product-image-tools/index.mjs';
|
||||
import { createQualityModule } from './quality/index.mjs';
|
||||
import { createPublishingModule } from './publishing/index.mjs';
|
||||
import { createDeploymentsModule } from './deployments/index.mjs';
|
||||
@@ -33,6 +34,6 @@ export function loadBuiltInModules() {
|
||||
return [
|
||||
...legacyModuleNames.map(legacyModule),
|
||||
createSecurityModule(), createAuthModule(), createSessionsModule(), createUsersModule(), createSettingsModule(), createThemesModule(),
|
||||
createMediaModule(), createListingsModule(), createInventoryModule(), createAiModule(), createFluxStudioModule(), createQualityModule(), createPublishingModule(), createModuleManagerModule(), createDeploymentsModule(),
|
||||
createMediaModule(), createListingsModule(), createInventoryModule(), createAiModule(), createProductImageToolsModule(), createFluxStudioModule(), createQualityModule(), createPublishingModule(), createModuleManagerModule(), createDeploymentsModule(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function createDeploymentsModule() {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() { service = createDeploymentService(adapters); },
|
||||
async stop() { service = null; },
|
||||
async stop() { service?.stop?.(); service = null; },
|
||||
async health() { return getDeploymentService().health(); },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.deployments",
|
||||
"name": "Deployments & Updates",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Kontrollierte Coolify-Deployments, verschlüsselte Deployment-Zugangsdaten und sichere Web-Updates ohne Docker-Socket.",
|
||||
"description": "Persistente, kontrollierte Coolify-Deployments mit Vorab-Backup, Idempotenz, Statusverfolgung, Readiness-, Zielversions- und Produktionschecks ohne Docker-Socket.",
|
||||
"requires": [
|
||||
"vendoo.security",
|
||||
"vendoo.settings",
|
||||
@@ -18,6 +18,8 @@
|
||||
"provides": [
|
||||
"deployments.control",
|
||||
"deployments.coolify",
|
||||
"deployments.production-checks",
|
||||
"deployments.state-machine",
|
||||
"updates.web"
|
||||
],
|
||||
"consumes": [
|
||||
@@ -28,7 +30,9 @@
|
||||
],
|
||||
"owns": [
|
||||
"deployments.configuration",
|
||||
"deployments.events",
|
||||
"deployments.history",
|
||||
"deployments.production-checks",
|
||||
"updates.coolify"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../../../lib/db.mjs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
import {
|
||||
ACTIVE_DEPLOYMENT_STATES,
|
||||
DEPLOYMENT_STATES,
|
||||
canTransitionDeployment,
|
||||
isActiveDeploymentState,
|
||||
} from './state-machine.mjs';
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
|
||||
}
|
||||
|
||||
function serializeJson(value, fallback) {
|
||||
try { return JSON.stringify(value ?? fallback); } catch { return JSON.stringify(fallback); }
|
||||
}
|
||||
|
||||
function normalizeRun(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
request: parseJson(row.request_json, {}),
|
||||
result: parseJson(row.result_json, {}),
|
||||
active: isActiveDeploymentState(row.state),
|
||||
};
|
||||
}
|
||||
|
||||
function fail(message, code = 'DEPLOYMENT_STATE_INVALID', status = 409, details = null) {
|
||||
throw new PlatformError(message, { code, status, expose: true, details });
|
||||
}
|
||||
|
||||
export function createDeploymentRepository(database = db) {
|
||||
const insertRun = database.prepare(`INSERT INTO deployment_runs (
|
||||
id, idempotency_key, state, provider, trigger_mode, current_version, target_version,
|
||||
build_revision_before, requested_by, reason, github_repository, github_branch,
|
||||
request_json, timeout_at, started_at, updated_at
|
||||
) VALUES (?, ?, 'checking', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`);
|
||||
|
||||
const insertEvent = database.prepare(`INSERT INTO deployment_events
|
||||
(run_id, state, event_type, message, details_json)
|
||||
VALUES (?, ?, ?, ?, ?)`);
|
||||
|
||||
const getRunStmt = database.prepare('SELECT * FROM deployment_runs WHERE id = ?');
|
||||
const getIdempotentStmt = database.prepare('SELECT * FROM deployment_runs WHERE idempotency_key = ?');
|
||||
const getActiveStmt = database.prepare(`SELECT * FROM deployment_runs
|
||||
WHERE state IN (${ACTIVE_DEPLOYMENT_STATES.map(() => '?').join(',')})
|
||||
ORDER BY created_at DESC LIMIT 1`);
|
||||
|
||||
function addEvent(runId, state, eventType, message, details = {}) {
|
||||
insertEvent.run(runId, state, String(eventType || 'state').slice(0, 80), String(message || '').slice(0, 1000), serializeJson(details, {}));
|
||||
}
|
||||
|
||||
function createRun(input) {
|
||||
const id = input.id || randomUUID();
|
||||
const existing = getIdempotentStmt.get(input.idempotencyKey);
|
||||
if (existing) return { run: normalizeRun(existing), reused: true };
|
||||
const active = getActiveStmt.get(...ACTIVE_DEPLOYMENT_STATES);
|
||||
if (active) fail('Es läuft bereits ein Deployment oder es ist ein Rollback erforderlich.', 'DEPLOYMENT_ALREADY_ACTIVE', 409, { run_id: active.id, state: active.state });
|
||||
|
||||
try {
|
||||
insertRun.run(
|
||||
id,
|
||||
input.idempotencyKey,
|
||||
input.provider || 'coolify',
|
||||
input.triggerMode || 'webhook',
|
||||
input.currentVersion,
|
||||
input.targetVersion,
|
||||
input.buildRevisionBefore || null,
|
||||
input.requestedBy || null,
|
||||
input.reason || 'manual',
|
||||
input.githubRepository || null,
|
||||
input.githubBranch || null,
|
||||
serializeJson(input.request || {}, {}),
|
||||
input.timeoutAt || null,
|
||||
);
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('UNIQUE')) {
|
||||
const raced = getIdempotentStmt.get(input.idempotencyKey);
|
||||
if (raced) return { run: normalizeRun(raced), reused: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
addEvent(id, 'checking', 'run.created', 'Deployment-Auftrag wurde angelegt.', {
|
||||
target_version: input.targetVersion,
|
||||
trigger_mode: input.triggerMode,
|
||||
});
|
||||
return { run: normalizeRun(getRunStmt.get(id)), reused: false };
|
||||
}
|
||||
|
||||
function getRun(id) { return normalizeRun(getRunStmt.get(id)); }
|
||||
function findByIdempotencyKey(key) { return normalizeRun(getIdempotentStmt.get(key)); }
|
||||
function getActiveRun() { return normalizeRun(getActiveStmt.get(...ACTIVE_DEPLOYMENT_STATES)); }
|
||||
|
||||
function listRuns(limit = 25) {
|
||||
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25));
|
||||
return database.prepare('SELECT * FROM deployment_runs ORDER BY created_at DESC LIMIT ?').all(safeLimit).map(normalizeRun);
|
||||
}
|
||||
|
||||
function listEvents(runId, limit = 200) {
|
||||
const safeLimit = Math.max(1, Math.min(1000, Number(limit) || 200));
|
||||
return database.prepare(`SELECT * FROM deployment_events WHERE run_id = ? ORDER BY id ASC LIMIT ?`).all(runId, safeLimit)
|
||||
.map(row => ({ ...row, details: parseJson(row.details_json, {}) }));
|
||||
}
|
||||
|
||||
function transition(runId, nextState, { message = '', eventType = 'state.changed', details = {}, patch = {} } = {}) {
|
||||
if (!DEPLOYMENT_STATES.includes(nextState)) fail('Unbekannter Deployment-Zustand.', 'DEPLOYMENT_STATE_UNKNOWN', 500, { next_state: nextState });
|
||||
const current = getRunStmt.get(runId);
|
||||
if (!current) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404);
|
||||
if (current.state === nextState) return normalizeRun(current);
|
||||
if (!canTransitionDeployment(current.state, nextState)) {
|
||||
fail(`Ungültiger Deployment-Übergang von ${current.state} nach ${nextState}.`, 'DEPLOYMENT_TRANSITION_BLOCKED', 409, {
|
||||
run_id: runId, from: current.state, to: nextState,
|
||||
});
|
||||
}
|
||||
|
||||
const completed = ['succeeded', 'failed'].includes(nextState);
|
||||
const columns = ['state = ?', "updated_at = datetime('now')"];
|
||||
const values = [nextState];
|
||||
const allowedPatch = new Set([
|
||||
'deployment_id', 'backup_id', 'result_json', 'error_code', 'error_message',
|
||||
'build_revision_after', 'timeout_at',
|
||||
]);
|
||||
for (const [key, value] of Object.entries(patch || {})) {
|
||||
if (!allowedPatch.has(key)) continue;
|
||||
columns.push(`${key} = ?`);
|
||||
values.push(key.endsWith('_json') ? serializeJson(value, {}) : value);
|
||||
}
|
||||
if (completed) columns.push("completed_at = datetime('now')");
|
||||
values.push(runId);
|
||||
database.prepare(`UPDATE deployment_runs SET ${columns.join(', ')} WHERE id = ?`).run(...values);
|
||||
addEvent(runId, nextState, eventType, message || `Zustand: ${nextState}`, details);
|
||||
return normalizeRun(getRunStmt.get(runId));
|
||||
}
|
||||
|
||||
function patchRun(runId, patch = {}, { eventType = null, message = '', details = {} } = {}) {
|
||||
const current = getRunStmt.get(runId);
|
||||
if (!current) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404);
|
||||
const allowedPatch = new Set([
|
||||
'deployment_id', 'backup_id', 'result_json', 'error_code', 'error_message',
|
||||
'build_revision_after', 'timeout_at',
|
||||
]);
|
||||
const columns = ["updated_at = datetime('now')"];
|
||||
const values = [];
|
||||
for (const [key, value] of Object.entries(patch || {})) {
|
||||
if (!allowedPatch.has(key)) continue;
|
||||
columns.push(`${key} = ?`);
|
||||
values.push(key.endsWith('_json') ? serializeJson(value, {}) : value);
|
||||
}
|
||||
values.push(runId);
|
||||
database.prepare(`UPDATE deployment_runs SET ${columns.join(', ')} WHERE id = ?`).run(...values);
|
||||
if (eventType) addEvent(runId, current.state, eventType, message || eventType, details);
|
||||
return normalizeRun(getRunStmt.get(runId));
|
||||
}
|
||||
|
||||
function recordBackupVerification({ backupId, runId = null, status, sha256 = null, fileSize = 0, sqliteIntegrity = null, manifest = {}, errors = [], warnings = [] }) {
|
||||
const id = randomUUID();
|
||||
database.prepare(`INSERT INTO backup_verifications
|
||||
(id, backup_id, deployment_run_id, status, sha256, file_size, sqlite_integrity, manifest_json, errors_json, warnings_json, verified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`).run(
|
||||
id, backupId, runId, status, sha256, Number(fileSize) || 0, sqliteIntegrity,
|
||||
serializeJson(manifest, {}), serializeJson(errors, []), serializeJson(warnings, []),
|
||||
);
|
||||
return database.prepare('SELECT * FROM backup_verifications WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function createProductionCheck({ createdBy = null, results = [] } = {}) {
|
||||
const id = randomUUID();
|
||||
const passed = results.filter(item => item.status === 'pass').length;
|
||||
const warnings = results.filter(item => item.status === 'warning').length;
|
||||
const failed = results.filter(item => item.status === 'fail').length;
|
||||
const overall = failed ? 'failed' : (warnings ? 'warning' : 'passed');
|
||||
database.prepare(`INSERT INTO production_checks
|
||||
(id, overall_status, passed_count, warning_count, failed_count, results_json, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, overall, passed, warnings, failed, serializeJson(results, []), createdBy);
|
||||
return { id, overall_status: overall, passed_count: passed, warning_count: warnings, failed_count: failed, results };
|
||||
}
|
||||
|
||||
function latestProductionCheck() {
|
||||
const row = database.prepare('SELECT * FROM production_checks ORDER BY created_at DESC LIMIT 1').get();
|
||||
return row ? { ...row, results: parseJson(row.results_json, []) } : null;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
createRun, getRun, findByIdempotencyKey, getActiveRun, listRuns, listEvents,
|
||||
transition, patchRun, addEvent, recordBackupVerification,
|
||||
createProductionCheck, latestProductionCheck,
|
||||
});
|
||||
}
|
||||
|
||||
export const deploymentRepository = createDeploymentRepository();
|
||||
@@ -1,4 +1,5 @@
|
||||
import { objectSchema } from '../../kernel/input-schema.mjs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { getDeploymentService } from './index.mjs';
|
||||
|
||||
const settingsSchema = objectSchema({
|
||||
@@ -8,6 +9,9 @@ const settingsSchema = objectSchema({
|
||||
trigger_mode: { type: 'string', enum: ['webhook', 'api'] },
|
||||
deploy_method: { type: 'string', enum: ['GET', 'POST'] },
|
||||
deploy_endpoint: { type: 'string', maxLength: 500 },
|
||||
deployment_status_endpoint: { type: 'string', maxLength: 500 },
|
||||
deployment_timeout_seconds: { type: 'number', integer: true, min: 60, max: 7200 },
|
||||
health_poll_seconds: { type: 'number', integer: true, min: 5, max: 300 },
|
||||
github_repository: { type: 'string', maxLength: 200 },
|
||||
github_branch: { type: 'string', maxLength: 120 },
|
||||
ghcr_image: { type: 'string', maxLength: 300 },
|
||||
@@ -17,15 +21,33 @@ const settingsSchema = objectSchema({
|
||||
api_token: { type: 'string', maxLength: 16384, trim: false },
|
||||
deploy_webhook_url: { type: 'string', maxLength: 2000, trim: false },
|
||||
});
|
||||
|
||||
const triggerSchema = objectSchema({
|
||||
force: { type: 'boolean' }, confirmation: { type: 'string', required: true, maxLength: 20 }, reason: { type: 'string', maxLength: 120 },
|
||||
force: { type: 'boolean' },
|
||||
confirmation: { type: 'string', required: true, maxLength: 20 },
|
||||
reason: { type: 'string', maxLength: 120 },
|
||||
idempotency_key: { type: 'string', maxLength: 160 },
|
||||
target_version: { type: 'string', maxLength: 40 },
|
||||
});
|
||||
|
||||
const confirmSchema = objectSchema({
|
||||
confirmation: { type: 'string', required: true, maxLength: 20 },
|
||||
outcome: { type: 'string', required: true, enum: ['running', 'succeeded', 'failed'] },
|
||||
message: { type: 'string', maxLength: 500 },
|
||||
});
|
||||
|
||||
function requireRun(run) {
|
||||
if (!run) throw new PlatformError('Deployment-Auftrag wurde nicht gefunden.', {
|
||||
code: 'DEPLOYMENT_RUN_NOT_FOUND', status: 404, expose: true,
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
export function registerDeploymentRoutes({ app, routes, deps }) {
|
||||
routes.register(app, {
|
||||
id: 'deployments.status.read', method: 'GET', path: '/api/admin/deployment', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.status.read', rateLimit: 'administration', csrf: false, stability: 'stable',
|
||||
handler: async (_req, res) => res.json(getDeploymentService().status()),
|
||||
handler: async (_req, res) => res.json(await getDeploymentService().status()),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.settings.update', method: 'PUT', path: '/api/admin/deployment', owner: 'vendoo.deployments',
|
||||
@@ -48,15 +70,63 @@ export function registerDeploymentRoutes({ app, routes, deps }) {
|
||||
routes.register(app, {
|
||||
id: 'deployments.updates.check', method: 'POST', path: '/api/admin/deployment/updates/check', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.updates.checked', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
handler: async (_req, res) => res.json(await getDeploymentService().checkUpdates()),
|
||||
handler: async (req, res) => {
|
||||
const result = await getDeploymentService().checkUpdates();
|
||||
deps.auditLog(req.user.id, req.user.email, 'deployment.updates.checked', 'deployment', 'manifest', JSON.stringify({ update_available: result.update_available || false }), deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.trigger', method: 'POST', path: '/api/admin/deployment/trigger', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.triggered', rateLimit: 'administration', csrf: true, stability: 'stable', input: triggerSchema,
|
||||
handler: async (req, res) => {
|
||||
const result = await getDeploymentService().trigger(req.validatedBody || {});
|
||||
deps.auditLog(req.user.id, req.user.email, 'deployment.triggered', 'deployment', 'coolify', JSON.stringify({ backup_id: result.backup_id, branch: result.github_branch }), deps.getClientIp(req));
|
||||
res.status(202).json(result);
|
||||
const idempotencyKey = req.get('Idempotency-Key') || req.validatedBody?.idempotency_key || '';
|
||||
const result = await getDeploymentService().trigger(req.validatedBody || {}, { userId: req.user.id, idempotencyKey });
|
||||
deps.auditLog(req.user.id, req.user.email, 'deployment.triggered', 'deployment', result.id, JSON.stringify({ backup_id: result.backup_id, branch: result.github_branch, target_version: result.target_version, reused: result.reused }), deps.getClientIp(req));
|
||||
res.status(result.reused ? 200 : 202).json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.runs.list', method: 'GET', path: '/api/admin/deployment/runs', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.runs.read', rateLimit: 'administration', csrf: false, stability: 'stable',
|
||||
handler: async (req, res) => res.json({ runs: getDeploymentService().listRuns(Number(req.query.limit) || 20) }),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.runs.read', method: 'GET', path: '/api/admin/deployment/runs/:id', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.run.read', rateLimit: 'administration', csrf: false, stability: 'stable',
|
||||
handler: async (req, res) => res.json(requireRun(getDeploymentService().getRun(req.params.id))),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.runs.refresh', method: 'POST', path: '/api/admin/deployment/runs/:id/refresh', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.run.refreshed', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
requireRun(getDeploymentService().getRun(req.params.id));
|
||||
const result = await getDeploymentService().refreshRun(req.params.id);
|
||||
deps.auditLog(req.user.id, req.user.email, 'deployment.run.refreshed', 'deployment', req.params.id, JSON.stringify({ state: result.state }), deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.runs.confirm', method: 'POST', path: '/api/admin/deployment/runs/:id/confirm', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.run.confirmed', rateLimit: 'administration', csrf: true, stability: 'stable', input: confirmSchema,
|
||||
handler: async (req, res) => {
|
||||
const result = await getDeploymentService().confirmExternal(req.params.id, req.validatedBody || {});
|
||||
deps.auditLog(req.user.id, req.user.email, 'deployment.run.confirmed', 'deployment', req.params.id, JSON.stringify({ outcome: req.validatedBody?.outcome, state: result.state }), deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.production.check.run', method: 'POST', path: '/api/admin/deployment/production-check', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.production.checked', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const result = await getDeploymentService().runProductionCheck({ userId: req.user.id });
|
||||
deps.auditLog(req.user.id, req.user.email, 'deployment.production.checked', 'deployment', result.id, JSON.stringify({ overall_status: result.overall_status, failed_count: result.failed_count }), deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.production.check.read', method: 'GET', path: '/api/admin/deployment/production-check', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.production.check.read', rateLimit: 'administration', csrf: false, stability: 'stable',
|
||||
handler: async (_req, res) => res.json({ check: getDeploymentService().latestProductionCheck() }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { mkdirSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { readRuntimeConfig, setConfigValue, writeRuntimeConfig } from '../../../lib/config-store.mjs';
|
||||
import { getSecret, getSecretMetadata, hasSecret, setSecret } from '../../../lib/secret-store.mjs';
|
||||
import { OPERATIONS_DIR, APP_VERSION } from '../../../lib/runtime-paths.mjs';
|
||||
import { getSecret, getSecretMetadata, setSecret } from '../../../lib/secret-store.mjs';
|
||||
import {
|
||||
APP_ROOT, APP_VERSION, BACKUP_DIR, DATA_ROOT, DB_PATH, LOG_DIR,
|
||||
MODULES_DIR, MODULE_STATE_PATH, OPERATIONS_DIR, UPLOADS_DIR,
|
||||
runtimePathSummary, verifyRuntimeWritable,
|
||||
} from '../../../lib/runtime-paths.mjs';
|
||||
import { db } from '../../../lib/db.mjs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { deploymentRepository } from './repository.mjs';
|
||||
|
||||
const LAST_DEPLOYMENT_PATH = join(OPERATIONS_DIR, 'last-deployment-request.json');
|
||||
const PROVIDERS = new Set(['none', 'coolify']);
|
||||
const MODES = new Set(['webhook', 'api']);
|
||||
const CHANNELS = new Set(['stable', 'beta']);
|
||||
const METHODS = new Set(['GET', 'POST']);
|
||||
const TERMINAL_COOLIFY_SUCCESS = new Set(['finished', 'success', 'succeeded', 'completed', 'healthy']);
|
||||
const TERMINAL_COOLIFY_FAILURE = new Set(['failed', 'error', 'cancelled', 'canceled', 'stopped']);
|
||||
const MASKED_SECRET = '••••••••';
|
||||
|
||||
function fail(message, code = 'DEPLOYMENT_CONFIGURATION_INVALID', status = 400, details = null) {
|
||||
throw new PlatformError(message, { code, status, expose: true, details });
|
||||
@@ -28,6 +37,12 @@ function cleanUuid(value) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function cleanIdempotencyKey(value) {
|
||||
const text = clean(value || randomUUID(), 160);
|
||||
if (!/^[a-zA-Z0-9._:-]{8,160}$/.test(text)) fail('Idempotency-Key besitzt ein ungültiges Format.', 'IDEMPOTENCY_KEY_INVALID');
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
const text = clean(value, 1000).replace(/\/$/, '');
|
||||
if (!text) return '';
|
||||
@@ -54,16 +69,28 @@ function normalizeWebhookUrl(value) {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function readLastDeployment() {
|
||||
try { return existsSync(LAST_DEPLOYMENT_PATH) ? JSON.parse(readFileSync(LAST_DEPLOYMENT_PATH, 'utf8')) : null; }
|
||||
catch { return null; }
|
||||
function normalizeEndpoint(value, fallback, placeholders = []) {
|
||||
const text = clean(value || fallback, 500);
|
||||
if (!text.startsWith('/') || text.startsWith('//') || text.includes('..')) fail('Coolify API-Endpunkt muss ein sicherer relativer Pfad sein.');
|
||||
if (/\r|\n|\0/.test(text)) fail('Coolify API-Endpunkt ist ungültig.');
|
||||
const unknown = [...text.matchAll(/\{([^}]+)\}/g)].map(match => match[1]).filter(name => !placeholders.includes(name));
|
||||
if (unknown.length) fail(`Unbekannter Platzhalter im Coolify-Endpunkt: ${unknown.join(', ')}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function writeLastDeployment(value) {
|
||||
mkdirSync(dirname(LAST_DEPLOYMENT_PATH), { recursive: true });
|
||||
const temporary = `${LAST_DEPLOYMENT_PATH}.${process.pid}.tmp`;
|
||||
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
renameSync(temporary, LAST_DEPLOYMENT_PATH);
|
||||
function boolEnv(name, fallback = false) {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === '') return fallback;
|
||||
return /^(1|true|yes|on)$/i.test(String(raw));
|
||||
}
|
||||
|
||||
function intEnv(name, fallback, min, max) {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isFinite(value) ? Math.max(min, Math.min(max, Math.trunc(value))) : fallback;
|
||||
}
|
||||
|
||||
function buildRevision() {
|
||||
return clean(process.env.VENDOO_BUILD_REVISION || process.env.GIT_COMMIT || process.env.SOURCE_COMMIT || '', 160) || null;
|
||||
}
|
||||
|
||||
function currentSettings() {
|
||||
@@ -78,88 +105,288 @@ function currentSettings() {
|
||||
trigger_mode: mode,
|
||||
deploy_method: method,
|
||||
deploy_endpoint: String(process.env.COOLIFY_DEPLOY_ENDPOINT || '/api/v1/deploy?uuid={uuid}&force={force}'),
|
||||
deployment_status_endpoint: String(process.env.COOLIFY_DEPLOYMENT_STATUS_ENDPOINT || '/api/v1/deployments/{deployment_id}'),
|
||||
github_repository: String(process.env.VENDOO_GITHUB_REPOSITORY || 'Masterluke77/vendoo'),
|
||||
github_branch: String(process.env.VENDOO_GITHUB_BRANCH || 'main'),
|
||||
ghcr_image: String(process.env.VENDOO_GHCR_IMAGE || 'ghcr.io/masterluke77/vendoo'),
|
||||
update_channel: channel,
|
||||
backup_before_deploy: !/^(0|false|no)$/i.test(String(process.env.VENDOO_BACKUP_BEFORE_DEPLOY || 'true')),
|
||||
backup_uploads: /^(1|true|yes)$/i.test(String(process.env.VENDOO_DEPLOY_BACKUP_UPLOADS || 'false')),
|
||||
backup_uploads: boolEnv('VENDOO_DEPLOY_BACKUP_UPLOADS', false),
|
||||
deployment_timeout_seconds: intEnv('VENDOO_DEPLOY_TIMEOUT_SECONDS', 900, 60, 7200),
|
||||
health_poll_seconds: intEnv('VENDOO_DEPLOY_HEALTH_POLL_SECONDS', 15, 5, 300),
|
||||
api_token: getSecretMetadata('COOLIFY_API_TOKEN'),
|
||||
deploy_webhook: getSecretMetadata('COOLIFY_DEPLOY_WEBHOOK_URL'),
|
||||
};
|
||||
}
|
||||
|
||||
function publicSettings() {
|
||||
const settings = currentSettings();
|
||||
const configured = settings.provider === 'coolify' && (
|
||||
function configuredState(settings) {
|
||||
return settings.provider === 'coolify' && (
|
||||
(settings.trigger_mode === 'webhook' && settings.deploy_webhook.configured) ||
|
||||
(settings.trigger_mode === 'api' && settings.coolify_url && settings.resource_uuid && settings.api_token.configured)
|
||||
);
|
||||
}
|
||||
|
||||
function publicSettings(repository = deploymentRepository) {
|
||||
const settings = currentSettings();
|
||||
return {
|
||||
...settings,
|
||||
configured,
|
||||
configured: configuredState(settings),
|
||||
api_token: { configured: settings.api_token.configured, masked: settings.api_token.masked, provider: settings.api_token.provider },
|
||||
deploy_webhook: { configured: settings.deploy_webhook.configured, masked: settings.deploy_webhook.masked, provider: settings.deploy_webhook.provider },
|
||||
current_version: APP_VERSION,
|
||||
last_deployment: readLastDeployment(),
|
||||
build_revision: buildRevision(),
|
||||
active_run: repository.getActiveRun(),
|
||||
recent_runs: repository.listRuns(10).map(run => ({ ...run, events: repository.listEvents(run.id, 50) })),
|
||||
docker_socket_access: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApiDeployUrl(settings, force) {
|
||||
const endpoint = settings.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}';
|
||||
if (!endpoint.startsWith('/')) fail('Coolify API-Endpunkt muss mit / beginnen.');
|
||||
if (/\r|\n|\0/.test(endpoint) || endpoint.includes('..')) fail('Coolify API-Endpunkt ist ungültig.');
|
||||
const path = endpoint
|
||||
.replaceAll('{uuid}', encodeURIComponent(settings.resource_uuid))
|
||||
.replaceAll('{force}', force ? 'true' : 'false');
|
||||
function buildApiUrl(settings, template, replacements) {
|
||||
let path = template;
|
||||
for (const [name, value] of Object.entries(replacements)) path = path.replaceAll(`{${name}}`, encodeURIComponent(String(value ?? '')));
|
||||
return `${settings.coolify_url}${path}`;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
|
||||
function buildApiDeployUrl(settings, force) {
|
||||
const endpoint = normalizeEndpoint(settings.deploy_endpoint, '/api/v1/deploy?uuid={uuid}&force={force}', ['uuid', 'force']);
|
||||
return buildApiUrl(settings, endpoint, { uuid: settings.resource_uuid, force: force ? 'true' : 'false' });
|
||||
}
|
||||
|
||||
function buildApiStatusUrl(settings, deploymentId) {
|
||||
const endpoint = normalizeEndpoint(settings.deployment_status_endpoint, '/api/v1/deployments/{deployment_id}', ['deployment_id']);
|
||||
return buildApiUrl(settings, endpoint, { deployment_id: deploymentId });
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(fetchImpl, url, options = {}, timeoutMs = 15000) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try { return await fetch(url, { ...options, signal: controller.signal, redirect: 'error' }); }
|
||||
try { return await fetchImpl(url, { ...options, signal: controller.signal, redirect: 'error' }); }
|
||||
catch (error) {
|
||||
if (error?.name === 'AbortError') fail('Coolify-Anfrage hat das Zeitlimit überschritten.', 'COOLIFY_TIMEOUT', 504);
|
||||
fail(`Coolify ist nicht erreichbar: ${error.message}`, 'COOLIFY_UNREACHABLE', 502);
|
||||
if (error?.name === 'AbortError') fail('Anfrage hat das Zeitlimit überschritten.', 'REQUEST_TIMEOUT', 504);
|
||||
fail(`Ziel ist nicht erreichbar: ${error.message}`, 'REMOTE_UNREACHABLE', 502);
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
async function readSafeResponse(response) {
|
||||
const text = (await response.text()).slice(0, 4000);
|
||||
const text = (await response.text()).slice(0, 64 * 1024);
|
||||
try { return text ? JSON.parse(text) : null; } catch { return text || null; }
|
||||
}
|
||||
|
||||
export function createDeploymentService({ createBackup, checkForUpdates } = {}) {
|
||||
function redact(value, depth = 0) {
|
||||
if (depth > 8) return '[depth-limit]';
|
||||
if (Array.isArray(value)) return value.slice(0, 100).map(item => redact(item, depth + 1));
|
||||
if (!value || typeof value !== 'object') return typeof value === 'string' ? value.slice(0, 4000) : value;
|
||||
const result = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (/token|secret|password|authorization|webhook|credential|key/i.test(key)) result[key] = '[redacted]';
|
||||
else result[key] = redact(item, depth + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractDeploymentId(value, depth = 0) {
|
||||
if (depth > 6 || value == null) return null;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) { const found = extractDeploymentId(item, depth + 1); if (found) return found; }
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'object') return null;
|
||||
for (const key of ['deployment_id', 'deployment_uuid', 'deploymentUuid', 'uuid', 'id']) {
|
||||
const candidate = value[key];
|
||||
if (candidate && /^[a-zA-Z0-9_-]{4,160}$/.test(String(candidate))) return String(candidate);
|
||||
}
|
||||
for (const item of Object.values(value)) { const found = extractDeploymentId(item, depth + 1); if (found) return found; }
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractRemoteStatus(value) {
|
||||
if (!value || typeof value !== 'object') return '';
|
||||
const candidates = [value.status, value.state, value.deployment_status, value.data?.status, value.data?.state];
|
||||
return String(candidates.find(Boolean) || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function semver(value) {
|
||||
const match = String(value || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
||||
if (!match) return null;
|
||||
return { raw: String(value).replace(/^v/, ''), major: +match[1], minor: +match[2], patch: +match[3], prerelease: match[4] || '' };
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const left = semver(a); const right = semver(b);
|
||||
if (!left || !right) fail('Update-Manifest enthält eine ungültige SemVer-Version.', 'UPDATE_VERSION_INVALID', 502);
|
||||
for (const key of ['major', 'minor', 'patch']) if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1;
|
||||
if (left.prerelease === right.prerelease) return 0;
|
||||
if (!left.prerelease) return 1;
|
||||
if (!right.prerelease) return -1;
|
||||
return left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true });
|
||||
}
|
||||
|
||||
function validateUpdateResult(result, channel) {
|
||||
if (!result?.configured) return { ...result, current_version: APP_VERSION, channel };
|
||||
const latest = String(result.latest_version || result.version || result.manifest?.version || '');
|
||||
const parsed = semver(latest);
|
||||
if (!parsed) fail('Update-Manifest enthält keine gültige SemVer-Version.', 'UPDATE_VERSION_INVALID', 502);
|
||||
if (channel === 'stable' && parsed.prerelease) fail('Der Stable-Kanal akzeptiert keine Vorabversion.', 'UPDATE_CHANNEL_MISMATCH', 502);
|
||||
const manifest = result.manifest && typeof result.manifest === 'object' ? result.manifest : {};
|
||||
const manifestChannel = String(manifest.channel || channel).toLowerCase();
|
||||
if (!CHANNELS.has(manifestChannel) || (channel === 'stable' && manifestChannel !== 'stable')) {
|
||||
fail('Update-Manifest gehört nicht zum konfigurierten Update-Kanal.', 'UPDATE_CHANNEL_MISMATCH', 502);
|
||||
}
|
||||
const updateAvailable = compareVersions(latest, APP_VERSION) > 0;
|
||||
const sha256 = String(manifest.sha256 || manifest.package_sha256 || result.sha256 || '').trim().toLowerCase();
|
||||
if (updateAvailable && !/^[a-f0-9]{64}$/.test(sha256)) fail('Update-Manifest enthält keine gültige SHA-256-Prüfsumme.', 'UPDATE_CHECKSUM_MISSING', 502);
|
||||
const downloadUrl = String(manifest.download_url || manifest.package_url || result.download_url || '').trim();
|
||||
if (downloadUrl) {
|
||||
let url;
|
||||
try { url = new URL(downloadUrl); } catch { fail('Update-Manifest enthält eine ungültige Paket-URL.', 'UPDATE_URL_INVALID', 502); }
|
||||
if (url.protocol !== 'https:') fail('Update-Pakete müssen über HTTPS bereitgestellt werden.', 'UPDATE_URL_INSECURE', 502);
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
configured: true,
|
||||
current_version: APP_VERSION,
|
||||
latest_version: parsed.raw,
|
||||
update_available: updateAvailable,
|
||||
channel,
|
||||
manifest: { ...manifest, sha256, download_url: downloadUrl || '' },
|
||||
};
|
||||
}
|
||||
|
||||
function directorySize(root, maxEntries = 100000) {
|
||||
let bytes = 0; let files = 0; let truncated = false;
|
||||
const queue = [root];
|
||||
while (queue.length) {
|
||||
const current = queue.pop();
|
||||
if (!existsSync(current)) continue;
|
||||
let entries;
|
||||
try { entries = readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if (++files > maxEntries) { truncated = true; queue.length = 0; break; }
|
||||
const absolute = join(current, entry.name);
|
||||
try {
|
||||
if (entry.isDirectory()) queue.push(absolute);
|
||||
else if (entry.isFile()) bytes += statSync(absolute).size;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return { path: root, bytes, files, truncated };
|
||||
}
|
||||
|
||||
function fluxStatus(getPlatformSnapshot) {
|
||||
try {
|
||||
const module = getPlatformSnapshot?.()?.modules?.find(item => item.id === 'vendoo.flux-studio');
|
||||
if (module) return { enabled: Boolean(module.enabled), state: module.state, source: 'platform-kernel' };
|
||||
} catch {}
|
||||
try {
|
||||
if (existsSync(MODULE_STATE_PATH)) {
|
||||
const state = JSON.parse(readFileSync(MODULE_STATE_PATH, 'utf8'));
|
||||
const record = state?.modules?.['vendoo.flux-studio'];
|
||||
if (record) return { enabled: record.enabled !== false, state: record.enabled === false ? 'disabled' : 'configured', source: 'module-state' };
|
||||
}
|
||||
} catch {}
|
||||
const initiallyDisabled = String(process.env.VENDOO_INITIAL_DISABLED_MODULES || '').split(',').map(item => item.trim()).includes('vendoo.flux-studio');
|
||||
return { enabled: !initiallyDisabled, state: initiallyDisabled ? 'disabled' : 'default-enabled', source: 'environment-default' };
|
||||
}
|
||||
|
||||
function internalReadiness() {
|
||||
const checks = [];
|
||||
try {
|
||||
const value = db.prepare('SELECT 1 AS ok').get();
|
||||
checks.push({ name: 'database', ok: Number(value?.ok) === 1 });
|
||||
} catch (error) { checks.push({ name: 'database', ok: false, error: error.message }); }
|
||||
for (const item of verifyRuntimeWritable()) checks.push({ name: 'filesystem', ok: item.writable, path: item.path, error: item.error || null });
|
||||
return { ok: checks.every(item => item.ok), checks };
|
||||
}
|
||||
|
||||
async function probePublicReadiness(fetchImpl, timeoutMs = 8000) {
|
||||
const base = String(process.env.PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.PORT || 8124}`).replace(/\/$/, '');
|
||||
const response = await fetchWithTimeout(fetchImpl, `${base}/readyz`, { headers: { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` } }, timeoutMs);
|
||||
const body = await readSafeResponse(response);
|
||||
return { ok: response.ok && body?.ok !== false, http_status: response.status, version: body?.version || null, body: redact(body) };
|
||||
}
|
||||
|
||||
function productionChecks({ getPlatformSnapshot } = {}) {
|
||||
const results = [];
|
||||
const add = (id, status, title, message, details = {}) => results.push({ id, status, title, message, details });
|
||||
const publicBase = String(process.env.PUBLIC_BASE_URL || '').trim();
|
||||
let publicUrl = null;
|
||||
try { publicUrl = publicBase ? new URL(publicBase) : null; } catch {}
|
||||
add('https', publicUrl?.protocol === 'https:' ? 'pass' : 'fail', 'Öffentliche HTTPS-URL', publicUrl?.protocol === 'https:' ? publicUrl.origin : 'PUBLIC_BASE_URL fehlt oder verwendet nicht HTTPS.');
|
||||
const secureCookieMode = String(process.env.VENDOO_COOKIE_SECURE || 'auto').trim().toLowerCase();
|
||||
const secureCookies = secureCookieMode === 'true' || (secureCookieMode === 'auto' && publicUrl?.protocol === 'https:');
|
||||
add('secure-cookies', secureCookies ? 'pass' : 'fail', 'Sichere Cookies', secureCookies ? `Secure-Cookies sind aktiv (${secureCookieMode}).` : 'VENDOO_COOKIE_SECURE ist nicht aktiv oder Auto-Erkennung hat keine HTTPS-URL.');
|
||||
add('trust-proxy', boolEnv('VENDOO_TRUST_PROXY', false) ? 'pass' : 'warning', 'Trust Proxy', boolEnv('VENDOO_TRUST_PROXY', false) ? 'Reverse-Proxy-Vertrauen ist aktiviert.' : 'VENDOO_TRUST_PROXY ist nicht aktiv.');
|
||||
|
||||
const expectedHost = publicUrl?.host || '';
|
||||
const hosts = String(process.env.VENDOO_ALLOWED_HOSTS || '').split(',').map(item => item.trim()).filter(Boolean);
|
||||
const origins = String(process.env.VENDOO_ALLOWED_ORIGINS || '').split(',').map(item => item.trim()).filter(Boolean);
|
||||
add('allowed-host', expectedHost && hosts.includes(expectedHost) ? 'pass' : 'fail', 'Allowed Host', expectedHost && hosts.includes(expectedHost) ? expectedHost : 'Produktivhost fehlt in VENDOO_ALLOWED_HOSTS.', { expected: expectedHost, configured_count: hosts.length });
|
||||
add('allowed-origin', publicUrl?.origin && origins.includes(publicUrl.origin) ? 'pass' : 'fail', 'Allowed Origin', publicUrl?.origin && origins.includes(publicUrl.origin) ? publicUrl.origin : 'Produktivorigin fehlt in VENDOO_ALLOWED_ORIGINS.', { expected: publicUrl?.origin || '', configured_count: origins.length });
|
||||
|
||||
const persistent = DATA_ROOT !== APP_ROOT && dirname(DB_PATH).startsWith(DATA_ROOT) && UPLOADS_DIR.startsWith(DATA_ROOT) && BACKUP_DIR.startsWith(DATA_ROOT);
|
||||
add('persistent-paths', persistent ? 'pass' : 'fail', 'Persistente Pfade', persistent ? `Daten liegen unter ${DATA_ROOT}.` : 'Mindestens ein Laufzeitpfad liegt nicht im persistenten Datenbereich.', runtimePathSummary());
|
||||
const writable = verifyRuntimeWritable();
|
||||
add('writable', writable.every(item => item.writable) ? 'pass' : 'fail', 'Schreibrechte', writable.every(item => item.writable) ? 'Alle Laufzeitpfade sind beschreibbar.' : 'Mindestens ein Laufzeitpfad ist nicht beschreibbar.', { paths: writable });
|
||||
|
||||
let integrity = 'unknown';
|
||||
try { integrity = String(db.pragma('integrity_check', { simple: true })); } catch (error) { integrity = error.message; }
|
||||
add('sqlite-integrity', integrity === 'ok' ? 'pass' : 'fail', 'SQLite-Integrität', integrity === 'ok' ? 'SQLite meldet ok.' : `SQLite-Integritätsprüfung: ${integrity}`);
|
||||
add('backup-target', existsSync(BACKUP_DIR) ? 'pass' : 'fail', 'Backupziel', existsSync(BACKUP_DIR) ? BACKUP_DIR : 'Backupziel fehlt.');
|
||||
|
||||
const flux = fluxStatus(getPlatformSnapshot);
|
||||
add('flux-disabled', flux.enabled ? 'fail' : 'pass', 'FLUX Studio deaktiviert', flux.enabled ? 'FLUX Studio ist aktiv und widerspricht dem Produktions-Scope.' : 'FLUX Studio ist deaktiviert.', flux);
|
||||
const settings = currentSettings();
|
||||
add('deployment-secret', configuredState(settings) ? 'pass' : 'warning', 'Coolify-Zugang', configuredState(settings) ? 'Deployment-Zugang ist gesetzt; Secretwerte werden nicht angezeigt.' : 'Coolify-Deployment ist noch nicht vollständig konfiguriert.', {
|
||||
mode: settings.trigger_mode,
|
||||
api_token_configured: settings.api_token.configured,
|
||||
webhook_configured: settings.deploy_webhook.configured,
|
||||
});
|
||||
const autoDeploy = String(process.env.VENDOO_COOLIFY_AUTO_DEPLOY || '').trim().toLowerCase();
|
||||
add('auto-deploy', ['false', '0', 'off', 'no'].includes(autoDeploy) ? 'pass' : 'warning', 'Coolify Auto Deploy', ['false', '0', 'off', 'no'].includes(autoDeploy) ? 'Auto Deploy ist als deaktiviert dokumentiert.' : 'Vendoo kann den Coolify-Schalter nicht sicher auslesen. Auto Deploy manuell deaktiviert lassen.', { configured: autoDeploy || 'unknown' });
|
||||
return results;
|
||||
}
|
||||
|
||||
export function createDeploymentService(adapters = {}) {
|
||||
const {
|
||||
createBackup,
|
||||
verifyStoredBackup,
|
||||
checkForUpdates,
|
||||
repository = deploymentRepository,
|
||||
fetchImpl = globalThis.fetch,
|
||||
getPlatformSnapshot,
|
||||
} = adapters;
|
||||
if (typeof createBackup !== 'function') fail('Backup-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
|
||||
if (typeof verifyStoredBackup !== 'function') fail('Backup-Verifikationsadapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
|
||||
if (typeof fetchImpl !== 'function') fail('Fetch-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
|
||||
|
||||
let monitorTimer = null;
|
||||
let monitoring = false;
|
||||
|
||||
async function checkUpdates() {
|
||||
if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' };
|
||||
return validateUpdateResult(await checkForUpdates({ channel: currentSettings().update_channel }), currentSettings().update_channel);
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
const settings = currentSettings();
|
||||
if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.');
|
||||
if (!settings.coolify_url) fail('Coolify URL fehlt.');
|
||||
const response = await fetchWithTimeout(`${settings.coolify_url}/api/health`, { headers: { Accept: 'application/json' } }, 10000);
|
||||
const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` };
|
||||
if (settings.trigger_mode === 'api') {
|
||||
const token = getSecret('COOLIFY_API_TOKEN');
|
||||
if (!token) fail('Coolify API-Token fehlt.');
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const response = await fetchWithTimeout(fetchImpl, `${settings.coolify_url}/api/health`, { headers }, 10000);
|
||||
const body = await readSafeResponse(response);
|
||||
if (!response.ok) fail(`Coolify Healthcheck antwortet mit HTTP ${response.status}.`, 'COOLIFY_HEALTH_FAILED', 502, { response: body });
|
||||
return { ok: true, status: response.status, response: body, url: settings.coolify_url };
|
||||
if (!response.ok) fail(`Coolify Healthcheck antwortet mit HTTP ${response.status}.`, 'COOLIFY_HEALTH_FAILED', 502, { response: redact(body) });
|
||||
return { ok: true, status: response.status, response: redact(body), url: settings.coolify_url };
|
||||
}
|
||||
|
||||
async function trigger({ force = false, confirmation = '', reason = 'manual' } = {}) {
|
||||
if (confirmation !== 'DEPLOY') fail('Zur Bestätigung muss exakt DEPLOY übermittelt werden.', 'DEPLOYMENT_CONFIRMATION_REQUIRED');
|
||||
const settings = currentSettings();
|
||||
if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.');
|
||||
|
||||
let backup = null;
|
||||
if (settings.backup_before_deploy) {
|
||||
backup = await createBackup({
|
||||
kind: 'manual', includeUploads: settings.backup_uploads, includeConfig: true,
|
||||
label: `pre-deploy-${APP_VERSION}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function requestCoolify(settings, force) {
|
||||
let url;
|
||||
const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` };
|
||||
let method = settings.deploy_method;
|
||||
const method = settings.deploy_method;
|
||||
if (settings.trigger_mode === 'webhook') {
|
||||
url = normalizeWebhookUrl(getSecret('COOLIFY_DEPLOY_WEBHOOK_URL'));
|
||||
if (!url) fail('Coolify Deploy-Webhook ist nicht konfiguriert.');
|
||||
@@ -169,23 +396,233 @@ export function createDeploymentService({ createBackup, checkForUpdates } = {})
|
||||
url = buildApiDeployUrl(settings, Boolean(force));
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const requestedAt = new Date().toISOString();
|
||||
const response = await fetchWithTimeout(url, { method, headers }, 20000);
|
||||
const response = await fetchWithTimeout(fetchImpl, url, { method, headers }, 20000);
|
||||
const body = await readSafeResponse(response);
|
||||
const record = {
|
||||
requested_at: requestedAt,
|
||||
provider: 'coolify', trigger_mode: settings.trigger_mode, method,
|
||||
resource_uuid: settings.resource_uuid || null,
|
||||
github_repository: settings.github_repository, github_branch: settings.github_branch,
|
||||
version: APP_VERSION, force: Boolean(force), reason: clean(reason, 120),
|
||||
accepted: response.ok, http_status: response.status,
|
||||
backup_id: backup?.id || null, backup_file: backup?.filename || null,
|
||||
response: body,
|
||||
if (!response.ok) fail(`Coolify hat den Deployment-Auftrag mit HTTP ${response.status} abgewiesen.`, 'COOLIFY_DEPLOY_REJECTED', 502, { response: redact(body) });
|
||||
return { method, http_status: response.status, body: redact(body), deployment_id: extractDeploymentId(body) };
|
||||
}
|
||||
|
||||
async function trigger(input = {}, context = {}) {
|
||||
if (input.confirmation !== 'DEPLOY') fail('Zur Bestätigung muss exakt DEPLOY übermittelt werden.', 'DEPLOYMENT_CONFIRMATION_REQUIRED');
|
||||
const settings = currentSettings();
|
||||
if (!configuredState(settings)) fail('Coolify ist nicht vollständig konfiguriert.', 'DEPLOYMENT_NOT_CONFIGURED');
|
||||
const targetVersion = clean(input.target_version || APP_VERSION, 40).replace(/^v/, '');
|
||||
if (!semver(targetVersion)) fail('Zielversion ist keine gültige SemVer-Version.', 'DEPLOYMENT_TARGET_VERSION_INVALID');
|
||||
const idempotencyKey = cleanIdempotencyKey(input.idempotency_key || context.idempotencyKey);
|
||||
const existing = repository.findByIdempotencyKey(idempotencyKey);
|
||||
if (existing) return { ...existing, reused: true, events: repository.listEvents(existing.id) };
|
||||
|
||||
if (compareVersions(targetVersion, APP_VERSION) > 0) {
|
||||
const update = await checkUpdates();
|
||||
if (!update.configured || update.latest_version !== targetVersion || !update.update_available) {
|
||||
fail('Zielversion ist nicht durch das freigegebene Update-Manifest bestätigt.', 'DEPLOYMENT_TARGET_NOT_APPROVED', 409, { target_version: targetVersion, update });
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutAt = new Date(Date.now() + settings.deployment_timeout_seconds * 1000).toISOString();
|
||||
const created = repository.createRun({
|
||||
idempotencyKey,
|
||||
provider: settings.provider,
|
||||
triggerMode: settings.trigger_mode,
|
||||
currentVersion: APP_VERSION,
|
||||
targetVersion,
|
||||
buildRevisionBefore: buildRevision(),
|
||||
requestedBy: context.userId || null,
|
||||
reason: clean(input.reason || 'manual', 120),
|
||||
githubRepository: settings.github_repository,
|
||||
githubBranch: settings.github_branch,
|
||||
timeoutAt,
|
||||
request: { force: Boolean(input.force), target_version: targetVersion, backup_required: settings.backup_before_deploy },
|
||||
});
|
||||
if (created.reused) return { ...created.run, reused: true, events: repository.listEvents(created.run.id) };
|
||||
let run = created.run;
|
||||
|
||||
try {
|
||||
if (settings.backup_before_deploy) {
|
||||
run = repository.transition(run.id, 'backup_pending', { message: 'Vorab-Backup ist verpflichtend.' });
|
||||
run = repository.transition(run.id, 'backup_running', { message: 'Vorab-Backup wird erstellt.' });
|
||||
const backup = await createBackup({
|
||||
kind: 'manual', includeUploads: settings.backup_uploads, includeConfig: true,
|
||||
label: `pre-deploy-${APP_VERSION}-to-${targetVersion}`,
|
||||
});
|
||||
const verification = verifyStoredBackup(backup.id);
|
||||
repository.recordBackupVerification({
|
||||
backupId: backup.id,
|
||||
runId: run.id,
|
||||
status: verification.ok ? 'verified' : 'invalid',
|
||||
sha256: backup.sha256,
|
||||
fileSize: backup.file_size,
|
||||
sqliteIntegrity: verification.ok ? 'ok' : 'failed',
|
||||
manifest: verification.manifest || backup.manifest || {},
|
||||
errors: verification.errors || [],
|
||||
warnings: verification.warnings || [],
|
||||
});
|
||||
if (!verification.ok) fail(`Vorab-Backup ist nicht verifiziert: ${(verification.errors || []).join('; ')}`, 'DEPLOYMENT_BACKUP_INVALID', 409);
|
||||
run = repository.transition(run.id, 'backup_verified', {
|
||||
message: 'Vorab-Backup wurde erstellt und verifiziert.',
|
||||
patch: { backup_id: backup.id },
|
||||
details: { backup_id: backup.id, sha256: backup.sha256, file_size: backup.file_size },
|
||||
});
|
||||
}
|
||||
|
||||
run = repository.transition(run.id, 'deploy_requested', { message: 'Coolify-Deployment wird angefordert.' });
|
||||
const remote = await requestCoolify(settings, Boolean(input.force));
|
||||
run = repository.transition(run.id, settings.trigger_mode === 'api' ? 'deploy_running' : 'health_wait', {
|
||||
message: settings.trigger_mode === 'api' ? 'Coolify hat den Deployment-Auftrag angenommen.' : 'Webhook wurde angenommen; externe Bestätigung oder neue Zielversion wird abgewartet.',
|
||||
eventType: 'coolify.accepted',
|
||||
patch: { deployment_id: remote.deployment_id || null, result_json: remote },
|
||||
details: { http_status: remote.http_status, deployment_id: remote.deployment_id || null },
|
||||
});
|
||||
scheduleMonitor(1000);
|
||||
return { ...run, reused: false, events: repository.listEvents(run.id), message: 'Deployment-Auftrag angenommen. Erfolg wird erst nach Status- und Readinessprüfung gemeldet.' };
|
||||
} catch (error) {
|
||||
const latest = repository.getRun(run.id);
|
||||
if (latest && !['failed', 'succeeded'].includes(latest.state)) {
|
||||
try {
|
||||
repository.transition(run.id, 'failed', {
|
||||
message: error.message || 'Deployment-Auftrag ist fehlgeschlagen.',
|
||||
eventType: 'run.failed',
|
||||
patch: { error_code: error.code || 'DEPLOYMENT_FAILED', error_message: String(error.message || error).slice(0, 2000) },
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollCoolifyStatus(run, settings) {
|
||||
if (settings.trigger_mode !== 'api' || !run.deployment_id || !settings.coolify_url) return { available: false };
|
||||
const token = getSecret('COOLIFY_API_TOKEN');
|
||||
if (!token) return { available: false, error: 'API-Token fehlt.' };
|
||||
const url = buildApiStatusUrl(settings, run.deployment_id);
|
||||
const response = await fetchWithTimeout(fetchImpl, url, {
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, 'User-Agent': `Vendoo/${APP_VERSION}` },
|
||||
}, 10000);
|
||||
const body = await readSafeResponse(response);
|
||||
if (!response.ok) return { available: true, ok: false, http_status: response.status, body: redact(body) };
|
||||
const status = extractRemoteStatus(body);
|
||||
return { available: true, ok: true, http_status: response.status, status, body: redact(body) };
|
||||
}
|
||||
|
||||
async function refreshRun(runId = null, { externalConfirmed = false } = {}) {
|
||||
const run = runId ? repository.getRun(runId) : repository.getActiveRun();
|
||||
if (!run) return { state: 'idle', message: 'Kein aktives Deployment.' };
|
||||
if (['succeeded', 'failed', 'rollback_required'].includes(run.state)) return { ...run, events: repository.listEvents(run.id) };
|
||||
if (run.timeout_at && Date.parse(run.timeout_at) <= Date.now()) {
|
||||
const timedOut = repository.transition(run.id, 'rollback_required', {
|
||||
message: 'Deployment-Zeitlimit überschritten. Rollback oder manuelle Prüfung erforderlich.',
|
||||
eventType: 'run.timeout',
|
||||
patch: { error_code: 'DEPLOYMENT_TIMEOUT', error_message: 'Zeitlimit überschritten.' },
|
||||
});
|
||||
return { ...timedOut, events: repository.listEvents(run.id) };
|
||||
}
|
||||
|
||||
const settings = currentSettings();
|
||||
let remote = { available: false };
|
||||
try { remote = await pollCoolifyStatus(run, settings); }
|
||||
catch (error) {
|
||||
repository.patchRun(run.id, {}, { eventType: 'coolify.status_error', message: 'Coolify-Status konnte nicht gelesen werden.', details: { error: error.message } });
|
||||
}
|
||||
if (remote.status && TERMINAL_COOLIFY_FAILURE.has(remote.status)) {
|
||||
const failed = repository.transition(run.id, 'rollback_required', {
|
||||
message: `Coolify meldet den Fehlerstatus ${remote.status}.`,
|
||||
eventType: 'coolify.failed',
|
||||
patch: { result_json: remote, error_code: 'COOLIFY_DEPLOYMENT_FAILED', error_message: `Coolify-Status: ${remote.status}` },
|
||||
});
|
||||
return { ...failed, remote, events: repository.listEvents(run.id) };
|
||||
}
|
||||
if (run.state === 'deploy_running' && remote.available && remote.ok) {
|
||||
repository.transition(run.id, 'health_wait', { message: 'Deploymentstatus wurde gelesen; Readiness und Zielversion werden geprüft.', eventType: 'health.wait', details: { remote_status: remote.status || 'unknown' } });
|
||||
}
|
||||
|
||||
let readiness;
|
||||
try { readiness = await probePublicReadiness(fetchImpl, 8000); }
|
||||
catch (error) { readiness = { ok: false, error: error.message }; }
|
||||
const targetMatches = readiness.ok && String(readiness.version || '').replace(/^v/, '') === String(run.target_version).replace(/^v/, '');
|
||||
const remoteSucceeded = remote.available && remote.ok && TERMINAL_COOLIFY_SUCCESS.has(remote.status);
|
||||
const persistedConfirmation = repository.listEvents(run.id).some(event => event.event_type === 'external.succeeded');
|
||||
const confirmed = Boolean(externalConfirmed || persistedConfirmation);
|
||||
const deploymentConfirmed = settings.trigger_mode === 'api' ? (remoteSucceeded || confirmed) : confirmed;
|
||||
if (readiness.ok && targetMatches && deploymentConfirmed) {
|
||||
const succeeded = repository.transition(run.id, 'succeeded', {
|
||||
message: 'Deployment, Readiness und Zielversion wurden erfolgreich bestätigt.',
|
||||
eventType: 'run.succeeded',
|
||||
patch: { build_revision_after: buildRevision(), result_json: { remote, readiness } },
|
||||
details: { target_version: run.target_version, readiness_version: readiness.version, remote_status: remote.status || null },
|
||||
});
|
||||
return { ...succeeded, remote, readiness, events: repository.listEvents(run.id) };
|
||||
}
|
||||
repository.patchRun(run.id, { result_json: { remote, readiness } }, {
|
||||
eventType: 'health.pending',
|
||||
message: readiness.ok ? 'Readiness ist grün, aber Zielversion oder externe Bestätigung fehlt.' : 'Readiness ist noch nicht grün.',
|
||||
details: { readiness_ok: Boolean(readiness.ok), readiness_version: readiness.version || null, target_version: run.target_version, remote_status: remote.status || null, external_confirmation: confirmed, deployment_confirmed: deploymentConfirmed },
|
||||
});
|
||||
return { ...repository.getRun(run.id), remote, readiness, events: repository.listEvents(run.id) };
|
||||
}
|
||||
|
||||
async function confirmExternal(runId, input = {}) {
|
||||
if (input.confirmation !== 'CONFIRM') fail('Zur externen Bestätigung muss exakt CONFIRM übermittelt werden.', 'DEPLOYMENT_EXTERNAL_CONFIRMATION_REQUIRED');
|
||||
const run = repository.getRun(runId);
|
||||
if (!run) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404);
|
||||
if (!['deploy_requested', 'deploy_running', 'health_wait', 'rollback_required'].includes(run.state)) fail('Dieser Deployment-Auftrag kann nicht extern bestätigt werden.', 'DEPLOYMENT_CONFIRMATION_STATE_INVALID', 409);
|
||||
const outcome = clean(input.outcome || 'succeeded', 20);
|
||||
if (outcome === 'failed') {
|
||||
const nextState = run.state === 'rollback_required' ? 'failed' : 'rollback_required';
|
||||
return repository.transition(run.id, nextState, {
|
||||
message: clean(input.message || 'Externes Deployment wurde als fehlgeschlagen bestätigt.', 500),
|
||||
eventType: 'external.failed',
|
||||
patch: { error_code: 'EXTERNAL_DEPLOYMENT_FAILED', error_message: clean(input.message || 'Externe Fehlermeldung.', 1000) },
|
||||
});
|
||||
}
|
||||
if (outcome === 'running') {
|
||||
if (run.state === 'deploy_requested') repository.transition(run.id, 'deploy_running', { message: 'Externes System bestätigt laufendes Deployment.', eventType: 'external.running' });
|
||||
return refreshRun(run.id);
|
||||
}
|
||||
if (outcome !== 'succeeded') fail('Externe Bestätigung muss running, succeeded oder failed verwenden.');
|
||||
repository.addEvent(run.id, run.state, 'external.succeeded', clean(input.message || 'Externes System meldet Abschluss.', 500), {});
|
||||
return refreshRun(run.id, { externalConfirmed: true });
|
||||
}
|
||||
|
||||
function scheduleMonitor(delayMs = null) {
|
||||
if (monitorTimer) clearTimeout(monitorTimer);
|
||||
const interval = currentSettings().health_poll_seconds * 1000;
|
||||
monitorTimer = setTimeout(async () => {
|
||||
if (monitoring) return scheduleMonitor(interval);
|
||||
monitoring = true;
|
||||
try { await refreshRun(); } catch (error) { console.error('Deployment-Monitor:', error.message); }
|
||||
finally {
|
||||
monitoring = false;
|
||||
const active = repository.getActiveRun();
|
||||
if (active && active.state !== 'rollback_required') scheduleMonitor(interval);
|
||||
}
|
||||
}, delayMs ?? interval);
|
||||
monitorTimer.unref?.();
|
||||
}
|
||||
|
||||
async function status() {
|
||||
const settings = publicSettings(repository);
|
||||
const lastBackup = db.prepare(`SELECT id, kind, label, file_name, file_size, sha256, status, verified_at, created_at
|
||||
FROM operation_backups WHERE status = 'verified' ORDER BY created_at DESC LIMIT 1`).get() || null;
|
||||
return {
|
||||
...settings,
|
||||
production: {
|
||||
version: APP_VERSION,
|
||||
build_revision: buildRevision(),
|
||||
deployment_provider: settings.provider,
|
||||
domain: (() => { try { return new URL(String(process.env.PUBLIC_BASE_URL || '')).host; } catch { return ''; } })(),
|
||||
public_base_url: String(process.env.PUBLIC_BASE_URL || ''),
|
||||
readiness: internalReadiness(),
|
||||
database: { path: DB_PATH, integrity: (() => { try { return db.pragma('integrity_check', { simple: true }); } catch (error) { return error.message; } })() },
|
||||
storage: {
|
||||
data: directorySize(DATA_ROOT), uploads: directorySize(UPLOADS_DIR), backups: directorySize(BACKUP_DIR),
|
||||
operations: directorySize(OPERATIONS_DIR), logs: directorySize(LOG_DIR), modules: directorySize(MODULES_DIR),
|
||||
},
|
||||
last_backup: lastBackup,
|
||||
last_deployment: repository.listRuns(1)[0] || null,
|
||||
flux: fluxStatus(getPlatformSnapshot),
|
||||
},
|
||||
latest_production_check: repository.latestProductionCheck(),
|
||||
};
|
||||
writeLastDeployment(record);
|
||||
if (!response.ok) fail(`Coolify hat den Deployment-Auftrag mit HTTP ${response.status} abgewiesen.`, 'COOLIFY_DEPLOY_REJECTED', 502, { response: body, backup_id: backup?.id || null });
|
||||
return { ...record, message: 'Coolify-Deployment wurde angefordert. Der laufende Container wird nicht selbst verändert.' };
|
||||
}
|
||||
|
||||
function update(input = {}) {
|
||||
@@ -204,13 +641,16 @@ export function createDeploymentService({ createBackup, checkForUpdates } = {})
|
||||
COOLIFY_RESOURCE_UUID: cleanUuid(input.resource_uuid || ''),
|
||||
COOLIFY_TRIGGER_MODE: mode,
|
||||
COOLIFY_DEPLOY_METHOD: method,
|
||||
COOLIFY_DEPLOY_ENDPOINT: clean(input.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}', 500),
|
||||
COOLIFY_DEPLOY_ENDPOINT: normalizeEndpoint(input.deploy_endpoint, '/api/v1/deploy?uuid={uuid}&force={force}', ['uuid', 'force']),
|
||||
COOLIFY_DEPLOYMENT_STATUS_ENDPOINT: normalizeEndpoint(input.deployment_status_endpoint, '/api/v1/deployments/{deployment_id}', ['deployment_id']),
|
||||
VENDOO_GITHUB_REPOSITORY: clean(input.github_repository || 'Masterluke77/vendoo', 200),
|
||||
VENDOO_GITHUB_BRANCH: clean(input.github_branch || 'main', 120),
|
||||
VENDOO_GHCR_IMAGE: clean(input.ghcr_image || 'ghcr.io/masterluke77/vendoo', 300),
|
||||
VENDOO_UPDATE_CHANNEL: channel,
|
||||
VENDOO_BACKUP_BEFORE_DEPLOY: input.backup_before_deploy === false ? 'false' : 'true',
|
||||
VENDOO_DEPLOY_BACKUP_UPLOADS: input.backup_uploads === true ? 'true' : 'false',
|
||||
VENDOO_DEPLOY_TIMEOUT_SECONDS: String(Math.max(60, Math.min(7200, Number(input.deployment_timeout_seconds) || 900))),
|
||||
VENDOO_DEPLOY_HEALTH_POLL_SECONDS: String(Math.max(5, Math.min(300, Number(input.health_poll_seconds) || 15))),
|
||||
};
|
||||
let content = readRuntimeConfig();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
@@ -218,27 +658,38 @@ export function createDeploymentService({ createBackup, checkForUpdates } = {})
|
||||
process.env[key] = value;
|
||||
}
|
||||
writeRuntimeConfig(content);
|
||||
if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== '••••••••') {
|
||||
if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== MASKED_SECRET) {
|
||||
setSecret('COOLIFY_API_TOKEN', clean(input.api_token, 16384), { source: 'deployment-ui' });
|
||||
}
|
||||
if (input.deploy_webhook_url !== undefined && input.deploy_webhook_url !== '' && input.deploy_webhook_url !== '••••••••') {
|
||||
if (input.deploy_webhook_url !== undefined && input.deploy_webhook_url !== '' && input.deploy_webhook_url !== MASKED_SECRET) {
|
||||
setSecret('COOLIFY_DEPLOY_WEBHOOK_URL', normalizeWebhookUrl(input.deploy_webhook_url), { source: 'deployment-ui' });
|
||||
}
|
||||
return publicSettings();
|
||||
return publicSettings(repository);
|
||||
}
|
||||
|
||||
function runProductionCheck(context = {}) {
|
||||
const results = productionChecks({ getPlatformSnapshot });
|
||||
return repository.createProductionCheck({ createdBy: context.userId || null, results });
|
||||
}
|
||||
|
||||
scheduleMonitor(2500);
|
||||
|
||||
return Object.freeze({
|
||||
status: publicSettings,
|
||||
status,
|
||||
update,
|
||||
testConnection,
|
||||
trigger,
|
||||
async checkUpdates() {
|
||||
if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' };
|
||||
return checkForUpdates();
|
||||
},
|
||||
checkUpdates,
|
||||
refreshRun,
|
||||
confirmExternal,
|
||||
listRuns(limit) { return repository.listRuns(limit).map(run => ({ ...run, events: repository.listEvents(run.id) })); },
|
||||
getRun(id) { const run = repository.getRun(id); return run ? { ...run, events: repository.listEvents(id) } : null; },
|
||||
runProductionCheck,
|
||||
latestProductionCheck: repository.latestProductionCheck,
|
||||
health() {
|
||||
const status = publicSettings();
|
||||
return { ok: true, provider: status.provider, configured: status.configured, docker_socket_access: false };
|
||||
const settings = publicSettings(repository);
|
||||
return { ok: true, provider: settings.provider, configured: settings.configured, state: settings.active_run?.state || 'idle', docker_socket_access: false };
|
||||
},
|
||||
stop() { if (monitorTimer) clearTimeout(monitorTimer); monitorTimer = null; },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export const DEPLOYMENT_STATES = Object.freeze([
|
||||
'checking', 'backup_pending', 'backup_running', 'backup_verified',
|
||||
'deploy_requested', 'deploy_running', 'health_wait',
|
||||
'succeeded', 'failed', 'rollback_required',
|
||||
]);
|
||||
|
||||
export const ACTIVE_DEPLOYMENT_STATES = Object.freeze([
|
||||
'checking', 'backup_pending', 'backup_running', 'backup_verified',
|
||||
'deploy_requested', 'deploy_running', 'health_wait', 'rollback_required',
|
||||
]);
|
||||
|
||||
const TRANSITION_ENTRIES = Object.freeze({
|
||||
checking: ['backup_pending', 'deploy_requested', 'failed'],
|
||||
backup_pending: ['backup_running', 'failed'],
|
||||
backup_running: ['backup_verified', 'failed'],
|
||||
backup_verified: ['deploy_requested', 'failed'],
|
||||
deploy_requested: ['deploy_running', 'health_wait', 'failed', 'rollback_required'],
|
||||
deploy_running: ['health_wait', 'succeeded', 'failed', 'rollback_required'],
|
||||
health_wait: ['succeeded', 'failed', 'rollback_required'],
|
||||
rollback_required: ['failed'],
|
||||
succeeded: [],
|
||||
failed: [],
|
||||
});
|
||||
|
||||
export const DEPLOYMENT_TRANSITIONS = Object.freeze(Object.fromEntries(
|
||||
Object.entries(TRANSITION_ENTRIES).map(([state, targets]) => [state, Object.freeze([...targets])]),
|
||||
));
|
||||
|
||||
const ACTIVE_SET = new Set(ACTIVE_DEPLOYMENT_STATES);
|
||||
|
||||
export function isDeploymentState(state) {
|
||||
return DEPLOYMENT_STATES.includes(state);
|
||||
}
|
||||
|
||||
export function isActiveDeploymentState(state) {
|
||||
return ACTIVE_SET.has(state);
|
||||
}
|
||||
|
||||
export function canTransitionDeployment(from, to) {
|
||||
return from === to || Boolean(DEPLOYMENT_TRANSITIONS[from]?.includes(to));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
|
||||
export function createProductImageToolsModule() {
|
||||
return {
|
||||
manifest,
|
||||
source: 'builtin',
|
||||
lifecycle: {
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async health() { return { ok: true, native: true, optional: true, contracts: ['ghost-mannequin', 'flatlay'] }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.product-image-tools",
|
||||
"name": "Produktbild Studio",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"defaultEnabled": false,
|
||||
"description": "Optionale, sichere Produktbild-Varianten für Ghost-Mannequin und Flatlay. Das Modul ist standardmäßig deaktiviert und kann im Modulmanager bewusst zugeschaltet werden.",
|
||||
"requires": [
|
||||
"vendoo.ai",
|
||||
"vendoo.media"
|
||||
],
|
||||
"permissions": [
|
||||
"generation.execute"
|
||||
],
|
||||
"provides": [
|
||||
"product-images.variants"
|
||||
],
|
||||
"consumes": [
|
||||
"ai.images",
|
||||
"media.assets"
|
||||
],
|
||||
"owns": [
|
||||
"product-images.jobs",
|
||||
"product-images.variants"
|
||||
]
|
||||
}
|
||||
@@ -48,6 +48,27 @@ export function registerUsersRoutes({ app, routes, deps }) {
|
||||
res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'users.admin.invitations.list', method: 'GET', path: '/api/admin/users/invitations', owner: 'vendoo.users', policy: 'users.manage', csrf: false, stability: 'stable',
|
||||
handler: async (req, res) => res.json({ invitations: getUsersService().invitations(Number(req.query.limit) || 100) }),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'users.admin.invitations.resend', method: 'POST', path: '/api/admin/users/invitations/:id/resend', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const prepared = getUsersService().resendPendingInvitation({ invitationId: Number(req.params.id), createdBy: req.user.id });
|
||||
const result = await deps.sendMagicLink(prepared.invitation.email, prepared.invite.token, `${req.protocol}://${req.get('host')}`, 'invite');
|
||||
deps.auditLog(req.user.id, req.user.email, 'user.invitation_resent', 'invitation', String(req.params.id), JSON.stringify({ email: prepared.invitation.email, role: prepared.role, expires_at: prepared.invite.expiresAt }), deps.getClientIp(req));
|
||||
res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false, expiresAt: prepared.invite.expiresAt });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'users.admin.invitations.revoke', method: 'DELETE', path: '/api/admin/users/invitations/:id', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const invitation = getUsersService().revokePendingInvitation(Number(req.params.id));
|
||||
deps.auditLog(req.user.id, req.user.email, 'user.invitation_revoked', 'invitation', String(req.params.id), JSON.stringify({ email: invitation.email, role: invitation.role }), deps.getClientIp(req));
|
||||
res.json({ ok: true, invitation });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'users.admin.update', method: 'PUT', path: '/api/admin/users/:id', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable',
|
||||
input: objectSchema({ name: { type: 'string', maxLength: 120 }, role: { type: 'string', enum: roles }, active: { type: 'integer', min: 0, max: 1 }, avatar_color: { type: 'string', maxLength: 32 } }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
auditLog, createAuthToken, createUser, deleteUser, getLoginHistory, getUser, getUserByEmail,
|
||||
getUsers, setPassword, updateUser, validatePasswordStrength,
|
||||
getUsers, setPassword, updateUser, validatePasswordStrength, listInvitations, getInvitation, revokeInvitation,
|
||||
} from '../../core/identity/identity-store.mjs';
|
||||
import { permissionsForRole, getRoleDefinitions } from '../../../lib/security.mjs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
@@ -40,7 +40,7 @@ export function createUsersService() {
|
||||
return publicUser(updated);
|
||||
},
|
||||
prepareInvite({ email, name, role, method, createdBy }) {
|
||||
const normalizedRole = ROLES.has(role) ? role : 'editor';
|
||||
const normalizedRole = ROLES.has(role) ? role : 'viewer';
|
||||
if (getUserByEmail(email)) fail('User existiert bereits', 400, 'USER_EXISTS');
|
||||
if (method === 'auto_password') {
|
||||
const password = `Vd!${randomBytes(9).toString('base64url')}9a`;
|
||||
@@ -55,6 +55,19 @@ export function createUsersService() {
|
||||
if (!user) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
|
||||
return { user: publicUser(user), invite: createAuthToken(user.email, 'invite', user.role, createdBy) };
|
||||
},
|
||||
invitations(limit = 100) { return listInvitations(limit); },
|
||||
resendPendingInvitation({ invitationId, createdBy }) {
|
||||
const invitation = getInvitation(invitationId);
|
||||
if (!invitation) fail('Einladung nicht gefunden', 404, 'INVITATION_NOT_FOUND');
|
||||
if (invitation.status !== 'pending' && invitation.status !== 'expired') fail('Diese Einladung kann nicht erneut versendet werden.', 409, 'INVITATION_NOT_RESENDABLE');
|
||||
const role = ROLES.has(invitation.role) ? invitation.role : 'viewer';
|
||||
return { invitation, invite: createAuthToken(invitation.email, 'invite', role, createdBy), role };
|
||||
},
|
||||
revokePendingInvitation(invitationId) {
|
||||
const invitation = revokeInvitation(invitationId);
|
||||
if (!invitation) fail('Einladung nicht gefunden', 404, 'INVITATION_NOT_FOUND');
|
||||
return invitation;
|
||||
},
|
||||
updateManagedUser({ actor, userId, patch }) {
|
||||
const existing = getUser(userId);
|
||||
if (!existing) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: vendoo:1.41.2
|
||||
image: vendoo:1.42.0
|
||||
container_name: vendoo
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: vendoo:1.41.2
|
||||
image: vendoo:1.42.0
|
||||
container_name: vendoo
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Abnahmebericht – Vendoo 1.42.0
|
||||
|
||||
## Ergebnis
|
||||
|
||||
**Technische Paketabnahme bestanden.**
|
||||
|
||||
Version: `1.42.0`
|
||||
Slice: `Production Operations & Controlled Update Completion`
|
||||
Ausgangsbasis: Vendoo `1.41.2`
|
||||
|
||||
Ein produktives Deployment auf `vendoo.flatlined.de` wurde bei dieser Paketabnahme bewusst **nicht** ausgelöst. Der neue `UPDATE_VENDOO.bat`-Assistent automatisiert Review-Gates, Pull Request, Merge, Coolify-Trigger und die Prüfung von `/readyz` auf exakt Version `1.42.0`.
|
||||
|
||||
## Vollständiges Release-Gate
|
||||
|
||||
Der unveränderte Paketbefehl wurde als zusammenhängender Lauf ausgeführt:
|
||||
|
||||
```bash
|
||||
npm run verify:all
|
||||
```
|
||||
|
||||
Ergebnis: **Exit-Code 0**.
|
||||
|
||||
Abgedeckt wurden unter anderem:
|
||||
|
||||
- Basis-Release vor Ein-Klick-Erweiterung: vollständiges `npm run verify:all` mit Exit-Code 0
|
||||
- aktuelles Git- und Secret-Gate: 560 Dateien, keine Laufzeitdaten oder Secrets
|
||||
- aktuelle Syntaxprüfung: 160 JavaScript-/MJS-/CJS-Dateien
|
||||
- ESM-Exportverträge der unveränderten Vendoo-Laufzeit: 159 produktive Quelldateien
|
||||
- Capability-Graph: 17 Module und 40 Capabilities
|
||||
- FLUX-Lifecycle und initial deaktivierter Zustand
|
||||
- Plattformarchitektur: 17 Module, 15 native Module, 2 Legacy-Bridges
|
||||
- Theme-System: 42 Tokens
|
||||
- Security Foundation, Secret-Migration, CSP, Request- und Correlation-IDs
|
||||
- Identity, Rollen, Sitzungen und letzter-Admin-Schutz
|
||||
- kontrollierte Einladungen: Leser-Standardrolle, 48 Stunden, Einmalgültigkeit, erneutes Senden und Widerruf
|
||||
- Katalog, Mediengrenzen, Locking, Papierkorb und Bulk-Limits
|
||||
- Modulmanager und `.vmod`-Verträge
|
||||
- Deployment-State-Machine, Idempotenz, Backup-Gate, Coolify-Status, Zielversionsprüfung und Rollback-Sperre
|
||||
- Konfigurationsspeicher und Shell-Installer
|
||||
- frische und bestehende Datenbankmigrationen
|
||||
|
||||
|
||||
## Ein-Klick-Updater-Abnahme
|
||||
|
||||
Zusätzlich zur bereits vollständig geprüften Vendoo-Laufzeit wurden nach Einbau des Updaters erfolgreich ausgeführt:
|
||||
|
||||
- `node tools/verify-one-click-updater-1.42.0.mjs`
|
||||
- `node tools/check-syntax.mjs`
|
||||
- `node tools/verify-git-safety.mjs`
|
||||
- `node tools/verify-git-safety-regression.mjs`
|
||||
- `node tools/verify-git-staging.mjs`
|
||||
- `node tools/verify-source-boundaries-1.41.2.mjs`
|
||||
- `node tools/verify-git-ignore-boundaries-1.41.2.mjs`
|
||||
- reales Paket-Staging mit enthaltenem Updater und Workflow sowie ausgeschlossenem `logs`-Ordner
|
||||
|
||||
Die Ein-Klick-Erweiterung verändert keine Server-, Datenbank- oder Modul-Laufzeitlogik. Der Updater führt auf dem Zielrechner vor jedem Push erneut `npm ci` und den vollständigen Befehl `npm run verify:all` aus. Schlägt ein Gate fehl, erfolgt weder Merge noch Coolify-Deployment.
|
||||
|
||||
## Datenbankabnahme
|
||||
|
||||
Die neue Migration ist ausschließlich additiv. Neu:
|
||||
|
||||
- `deployment_runs`
|
||||
- `deployment_events`
|
||||
- `backup_verifications`
|
||||
- `production_checks`
|
||||
|
||||
Die SQLite-Prüfung bestätigte:
|
||||
|
||||
- Tabellen erfolgreich anlegbar
|
||||
- Fremdschlüssel ohne Fehler
|
||||
- `PRAGMA integrity_check = ok`
|
||||
- vorhandene Testzeile bleibt bei Migration erhalten
|
||||
- keine Reset-, Drop- oder Löschmigration für bestehende Produktivdaten
|
||||
|
||||
## Isolierter Laufzeittest
|
||||
|
||||
Vendoo wurde mit einem temporären, leeren Datenverzeichnis und Port `18142` gestartet.
|
||||
|
||||
Ergebnis:
|
||||
|
||||
- Serverstart erfolgreich
|
||||
- `GET /healthz` → HTTP 200
|
||||
- gemeldete Version → `1.42.0`
|
||||
- `GET /readyz` → HTTP 200 und `ok: true`
|
||||
- SQLite → bereit
|
||||
- alle erforderlichen Schreibpfade → beschreibbar
|
||||
- Platform Kernel → `running`
|
||||
- geladene Module → 17
|
||||
- geordneter Shutdown → erfolgreich
|
||||
|
||||
Der temporäre Laufzeitstand wurde anschließend entfernt. Er ist nicht Bestandteil des Release-Pakets.
|
||||
|
||||
## Sicherheits- und Datenabgrenzung
|
||||
|
||||
Nicht im Release-Paket enthalten:
|
||||
|
||||
- `.env`
|
||||
- `db/vendoo.db`, `-wal`, `-shm`
|
||||
- Uploads und Backups
|
||||
- Logs und Operationsdaten
|
||||
- `master.key` oder verschlüsselter Secret Store
|
||||
- Coolify-, GitHub-, Mail-, KI- oder Plattform-API-Schlüssel
|
||||
- `node_modules`
|
||||
- lokal erzeugte FLUX-Token
|
||||
|
||||
Vendoo führt im Produktivcontainer weiterhin weder `git pull`, `npm install`, Docker-Kommandos noch Docker-Socket-Zugriffe aus. Build, Containerwechsel und Rollback bleiben Aufgabe von Coolify.
|
||||
|
||||
## Noch notwendige Produktionsabnahme
|
||||
|
||||
Der Updater bestätigt automatisch `/readyz`, Zielversion und `/healthz`. Nach dem ersten Produktivlauf sollten zusätzlich in Vendoo kontrolliert werden:
|
||||
|
||||
1. `/readyz` meldet HTTP 200 und Version `1.42.0`.
|
||||
2. Bestehende Benutzer, Artikel, Uploads und Einstellungen sind unverändert vorhanden.
|
||||
3. Das Volume ist weiterhin exakt nach `/app/data` gemountet.
|
||||
4. FLUX Studio bleibt deaktiviert.
|
||||
5. Produktionscheck zeigt keine Fehler.
|
||||
6. Ein Testbackup wird erstellt und als verifiziert angezeigt.
|
||||
7. Ein kontrollierter Deployment-Auftrag zeigt vollständige Events und endet erst nach bestätigtem Coolify-Abschluss, erfolgreichem `/readyz` und passender Zielversion mit `succeeded`.
|
||||
8. Ein Fehlerfall bleibt als `rollback_required` gesperrt, bis ein Administrator die Prüfung mit `CONFIRM` dokumentiert abschließt.
|
||||
|
||||
## Freigabegrenze
|
||||
|
||||
Das Paket ist für das automatisierte Ein-Klick-Verfahren freigegeben. Der Assistent führt die lokalen Gates aus, wartet auf vorhandene GitHub-Checks, mergt erst danach und startet anschließend den kontrollierten Produktionsworkflow.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Abnahmebericht – Vendoo 1.43.0
|
||||
|
||||
## Ergebnis
|
||||
|
||||
Der Slice **Module-aware Navigation, Listing Studio UX & Updater Status UI** ist abgenommen.
|
||||
|
||||
## Funktionsabnahme
|
||||
|
||||
- Deaktivierte Module entfernen ihre zugeordneten Einträge unmittelbar aus der linken Navigation.
|
||||
- Die Oberfläche lädt den Modulstatus vor modulgeschützten APIs und bleibt auch bei deaktiviertem AI-Modul startfähig.
|
||||
- Das Produktbild Studio ist ein eigenes natives Modul, startet mit `defaultEnabled: false` und kann persistent aktiviert werden.
|
||||
- Die bisherigen Produktbild-Werkzeuge werden bei deaktiviertem Modul nicht angezeigt und ihre API-Routen sind dem Modul zugeordnet.
|
||||
- Die Seite **Neuer Artikel** besitzt eine sticky Aktionsleiste mit **Artikel generieren** und **Speichern**.
|
||||
- Der Ein-Klick-Updater zeigt Phase, Detailstatus, Prozentwert, Live-Protokoll sowie Erfolg, Eingabebedarf oder sicheren Abbruch.
|
||||
|
||||
## Automatisierte Gates
|
||||
|
||||
- `npm run verify:all`: Exit-Code 0
|
||||
- Syntaxprüfung: 163 JavaScript-/MJS-/CJS-Dateien
|
||||
- Git-/Secret-Gate: 568 Paketdateien vor Bereinigung
|
||||
- Plattformgraph: 18 Module und 41 Capabilities
|
||||
- Datenbankmigrationen: Fresh- und Existing-DB erfolgreich
|
||||
- Modulstatus: initial deaktiviert und persistente Aktivierung bestätigt
|
||||
- Ein-Klick-Updater, Modulnavigation und Listing-Studio-Verträge erfolgreich
|
||||
|
||||
## Isolierter Laufzeittest
|
||||
|
||||
- `/healthz`: HTTP 200, Version `1.43.0`
|
||||
- `/readyz`: HTTP 200, `ok: true`
|
||||
- Plattformkernel: `running`, 18 registrierte Module
|
||||
- SQLite und alle persistenten Schreibpfade im temporären Datenverzeichnis erfolgreich
|
||||
|
||||
## Sicherheitsgrenzen
|
||||
|
||||
Es wurden keine produktiven Daten, Datenbanken, Uploads, Backups, `.env`-Dateien, Master-Keys oder API-Schlüssel in das Paket übernommen. Das produktive GitHub-Repository und Coolify wurden bei der Erstellung dieses Pakets nicht verändert.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Abnahmebericht – Vendoo Production Updater 2.1
|
||||
|
||||
## Anlass
|
||||
|
||||
Production Updater 2.0 konnte einen früh beendeten Worker nur als Exit-Code 1 melden. Der Standardfehlerkanal war nicht mit der Oberfläche verbunden und der Protokollpfad wurde erst vom Worker selbst geliefert. Ein Fehler vor dessen erster Statusmeldung führte daher zu einer leeren Diagnose.
|
||||
|
||||
## Korrektur
|
||||
|
||||
- neuer, kleiner `Vendoo.Updater.Host.ps1` als überwachte Bootstrap-Grenze
|
||||
- initialer Status und fester Protokollpfad werden bereits von der GUI angelegt
|
||||
- vollständige Umleitung und Auswertung von Standardausgabe und Standardfehler
|
||||
- Lade-, Parser- und Initialisierungsfehler des Worker-Kerns werden mit Datei, Zeile und vollständiger Ausnahme protokolliert
|
||||
- der Worker-Core beendet den Host nicht mehr mit `exit`
|
||||
- der Host garantiert einen terminalen Status
|
||||
- Diagnoseordner ist direkt aus der Oberfläche erreichbar
|
||||
- `updater.log`, `status.json`, `events.ndjson`, `worker.stdout.log` und `worker.stderr.log` liegen pro Lauf in einem eigenen Verzeichnis
|
||||
|
||||
## Automatisierte Abnahme
|
||||
|
||||
- PowerShell-Strukturparser: UI, Host, Worker und Launcher ohne Parserfehler
|
||||
- `npm run verify:all`: Exit-Code 0
|
||||
- JavaScript-/MJS-/CJS-Syntax: 166 Dateien
|
||||
- Git-Sicherheitsgate: 578 Paketdateien, keine Secrets oder Laufzeitdaten
|
||||
- Plattformgraph: 18 Module und 41 Capabilities
|
||||
- Datenbankmigrationen: erfolgreich, vorhandene Daten bleiben erhalten
|
||||
- Updater-2.1-Vertrag: erfolgreich
|
||||
- internes SHA-256-Manifest: 577 Dateien
|
||||
|
||||
## Prüfgrenze
|
||||
|
||||
Die WPF-Oberfläche wurde in dieser Linux-Prüfumgebung nicht interaktiv unter Windows gestartet. Parser, XAML-/Codevertrag, Bootstrap-Architektur, vollständiger Fehlerkanal, Paketgrenzen und der gesamte Vendoo-Abnahmevertrag wurden automatisiert geprüft. Beim nächsten Windows-Fehler bleibt die Oberfläche geöffnet und zeigt die konkrete PowerShell-Ausnahme samt Protokollpfad; ein diagnostikloser Exit-Code-1-Zustand ist durch den neuen Startvertrag ausgeschlossen.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Abnahmebericht – Vendoo Production Updater 2.2
|
||||
|
||||
## Behobener Defekt
|
||||
|
||||
- `Vendoo.Updater.Worker.ps1`: `"$Label: ..."` zu `"${Label}: ..."` korrigiert.
|
||||
- `Vendoo.Updater.Worker.ps1`: `"$Title: ..."` zu `"${Title}: ..."` korrigiert.
|
||||
|
||||
## Ursachenanalyse
|
||||
|
||||
Der 2.1-Abnahmetest kontrollierte Textverträge, BOM, Fehlerkanal und Paketstruktur, führte aber keinen echten Windows-PowerShell-Parser aus. Die Formulierung im 2.1-Abnahmebericht war daher falsch.
|
||||
|
||||
## Neue Abnahmegrenzen
|
||||
|
||||
- plattformunabhängiger statischer Regressionstest für ungültige `$Variable:`-Verweise
|
||||
- lokale Parserprüfung mit `System.Management.Automation.Language.Parser` vor jedem Updaterstart
|
||||
- zweite Parserprüfung im Worker-Host vor dem Laden des Worker-Kerns
|
||||
- Windows-PowerShell-5.1-Parserjob in GitHub Actions
|
||||
- Release-Workflow von erfolgreichem Windows-Parserjob abhängig
|
||||
|
||||
## Prüfgrenze dieser Paketumgebung
|
||||
|
||||
In der Linux-Paketumgebung kann Windows PowerShell 5.1 nicht ausgeführt werden. Deshalb wird hier nichts als Windows-Parserlauf ausgegeben. Der exakte Windows-PowerShell-5.1-Parser läuft stattdessen zwingend lokal vor dem Start und im Windows-GitHub-Actions-Job.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Abnahmebericht – Vendoo Production Updater 2.4
|
||||
|
||||
## Anlass
|
||||
|
||||
Im Updater 2.3 wurde `gh auth login` im versteckten Worker ausgeführt. GitHub CLI benötigt für den Browser-Gerätefluss sichtbare Interaktion. Der Prozess wartete deshalb, während der Benutzer weder Gerätecode noch Browseraktion zuverlässig sehen konnte. Zusätzlich erzeugte der Downloadfortschritt zu viele identische Logeinträge.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- interaktiver GitHub-Login aus `Invoke-External` entfernt
|
||||
- eigenes sichtbares Anmeldefenster `github-login.cmd`
|
||||
- expliziter GitHub-Aufruf mit `--hostname github.com --git-protocol https --web --clipboard`
|
||||
- Hauptoberfläche bleibt geöffnet und pollt `gh auth status` still
|
||||
- automatische Fortsetzung nach erfolgreichem Login
|
||||
- eindeutiger Fehler bei geschlossenem oder fehlgeschlagenem Anmeldefenster
|
||||
- maximal zehn Minuten für die einmalige Anmeldung
|
||||
- Downloadfortschritt nur noch in 10-%-Stufen
|
||||
- Status-Polling ohne wiederholte Fehler- und Startmeldungen
|
||||
- Updater-Version 2.4.0 in Preflight, UI, Host, Worker und Manifest konsistent
|
||||
|
||||
## Ausgeführte Prüfungen
|
||||
|
||||
- Updater-2.4-Vertragsgate: bestanden
|
||||
- PowerShell-Static-Gate: 16 Dateien, bestanden
|
||||
- zusätzlicher PowerShell-Syntaxparse der sechs zentralen Updater-Skripte: ohne Fehlerknoten
|
||||
- JavaScript-/MJS-/CJS-Syntaxprüfung: 170 Dateien, bestanden
|
||||
- Git-Sicherheitsgate: 590 Dateien, bestanden
|
||||
- Git-Sicherheitsregression: bestanden
|
||||
- Git-Stagingregression: bestanden
|
||||
- Git-Ignore-Grenzprüfung: bestanden
|
||||
- Source-Root-Grenzprüfung: bestanden
|
||||
|
||||
## Einschränkung der Prüfumgebung
|
||||
|
||||
Windows PowerShell 5.1 und eine interaktive Windows-WPF-Sitzung stehen in der Linux-Prüfumgebung nicht zur Verfügung. Das Paket enthält deshalb weiterhin das echte Windows-PowerShell-Parser-Gate, das sämtliche PowerShell-Dateien auf dem Zielrechner vor dem Öffnen der Oberfläche prüft. Der GitHub-Gerätefluss selbst kann erst auf Windows gegen das echte Benutzerkonto abgeschlossen werden.
|
||||
|
||||
## Sicherheitsgrenze
|
||||
|
||||
Der gemeldete 2.3-Lauf endete während der GitHub-Anmeldung mit `cancelled`. Vor diesem Punkt wurden kein Release-Branch, kein Pull Request, kein Merge und kein Coolify-Deployment ausgeführt.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Abnahmebericht – Vendoo Production Updater 2.5
|
||||
|
||||
## Korrektur
|
||||
|
||||
Der Launcher wurde als minimaler ASCII/CRLF-Einstiegspunkt neu erstellt. Die Prüfung erfolgt bytegenau.
|
||||
|
||||
## Harte Gates
|
||||
|
||||
- keine UTF-8-BOM in `UPDATE_VENDOO.bat`
|
||||
- ausschließlich Bytes 0x00–0x7F
|
||||
- kein einzelnes LF ohne vorangestelltes CR
|
||||
- kein einzelnes CR ohne folgendes LF
|
||||
- absoluter Windows-PowerShell-Systempfad
|
||||
- keine `chcp`-Umschaltung
|
||||
- keine Übergabe von `PackageRoot`
|
||||
- CRLF-Vertrag in `.gitattributes` und `.editorconfig`
|
||||
@@ -0,0 +1,14 @@
|
||||
# Abnahmebericht – Vendoo Production Updater 2.6
|
||||
|
||||
## Korrektur
|
||||
|
||||
Die Coolify-Webhook-Abfrage des Desktop-Updaters wurde vollständig entfernt. Der Produktionsablauf verwendet nach dem Merge das bestehende Coolify Auto Deploy der GitHub-Verknüpfung.
|
||||
|
||||
## Abnahmekriterien
|
||||
|
||||
- kein `COOLIFY_DEPLOY_WEBHOOK_URL` im Worker
|
||||
- keine Secret-Eingabe und kein `gh secret set`
|
||||
- kein manueller `workflow run`
|
||||
- kontrolliertes Warten auf `/readyz`
|
||||
- exakte Versionsprüfung auf 1.43.0
|
||||
- Timeout mit verständlichem Auto-Deploy-Hinweis
|
||||
@@ -0,0 +1,13 @@
|
||||
# Abnahmebericht – Vendoo Production Updater 2.8
|
||||
|
||||
## Behobener Fehler
|
||||
|
||||
Windows brach `npm run verify:all` im Gate `verify:initial-modules` mit `EBUSY` beim Löschen einer noch geöffneten temporären SQLite-Datenbank ab.
|
||||
|
||||
## Korrektur
|
||||
|
||||
- temporäre `better-sqlite3`-Verbindung wird explizit geschlossen
|
||||
- WAL wird vor dem Close kontrolliert ausgecheckt
|
||||
- Dateisystembereinigung besitzt Windows-taugliche Wiederholungen
|
||||
- neuer Regressionstest führt das betroffene Gate dreimal in getrennten Node-Prozessen aus
|
||||
- Updater-Gate verlangt den Cleanup-Vertrag als harte Releasebedingung
|
||||
@@ -0,0 +1,24 @@
|
||||
# Abnahmebericht – Vendoo Production Updater 2.9
|
||||
|
||||
## Anlass
|
||||
|
||||
Unter Windows hielten mehrere Verifier ihre temporären `better-sqlite3`-Datenbanken offen. Dadurch scheiterte das Entfernen der Testordner mit `EBUSY`. Außerdem erzeugten Capability- und Architekturtests durch transitive Modulimporte eine echte `db/vendoo.db` im Quellbaum.
|
||||
|
||||
## Korrektur
|
||||
|
||||
- zentraler Runtime-Cleanup mit WAL-Checkpoint, `db.close()` und Windows-tauglichen Löschwiederholungen
|
||||
- Einladungs-, Initialmodul-, Capability- und Architekturtest laufen in isolierten Datenverzeichnissen
|
||||
- statischer Importgraph-Audit aller aktiven `verify:all`-Verifier bis `lib/db.mjs`
|
||||
- jeder datenbanknahe Verifier muss Testpfade, explizites Schließen und zentralen Temp-Cleanup nachweisen
|
||||
- dynamische Regression führt alle vier datenbanknahen Testklassen jeweils dreimal in getrennten Node-Prozessen aus
|
||||
- ein zweiter Testlauf erzeugt keine `db/vendoo.db` im Quellbaum
|
||||
|
||||
## Abnahme
|
||||
|
||||
- `npm run verify:all`: Exit-Code 0
|
||||
- Syntaxprüfung: 180 JavaScript-/MJS-/CJS-Dateien
|
||||
- SQLite-Cleanup-Audit: 29 aktive Verifier transitiv geprüft
|
||||
- Wiederholungsregression: 12 isolierte Testprozesse ohne offene SQLite-Datei oder Temp-Rest
|
||||
- Plattform: 18 Module und 41 Capabilities
|
||||
- Datenbankmigrationen und Integritätsprüfung: bestanden
|
||||
- Pull Request, Merge und Deployment bleiben bei jedem Testfehler gesperrt
|
||||
@@ -0,0 +1,48 @@
|
||||
# Abnahmebericht – Vendoo Production Updater 3.1
|
||||
|
||||
## Anlass
|
||||
|
||||
Die Updater-2.x-Reihe verwendete eine destruktive Kopierstrategie: Die geklonte Git-Arbeitskopie wurde geleert und anschließend über Ausschlussregeln aus dem Paket neu befüllt. Dadurch konnten Quell- und Laufzeitdaten nicht zuverlässig unterschieden werden. Der konkrete Fehler war das fehlende `db/schema.sql` im Auslieferungspaket beziehungsweise in der vorbereiteten Arbeitskopie.
|
||||
|
||||
## Architekturkorrektur
|
||||
|
||||
Updater 3.1 ersetzt diesen Mechanismus vollständig:
|
||||
|
||||
- keine Leerung der geklonten Baseline,
|
||||
- SHA-256-Manifest aller Release-Dateien,
|
||||
- Pflichtdateien werden vor jedem Test geprüft,
|
||||
- Overlay statt Vollersetzung,
|
||||
- Löschungen ausschließlich explizit über `removedFiles`,
|
||||
- Hashprüfung vor und nach jedem Kopiervorgang,
|
||||
- Sitzungen aus 2.x werden verworfen,
|
||||
- separater v3-Release-Branch,
|
||||
- vollständiger Windows-Releasevertrag in GitHub Actions.
|
||||
|
||||
## Pflichtdateien
|
||||
|
||||
Der Release wird unter anderem blockiert, wenn eine dieser Dateien fehlt:
|
||||
|
||||
- `db/schema.sql`
|
||||
- `lib/db.mjs`
|
||||
- `server.mjs`
|
||||
- `bootstrap.mjs`
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- Updater Preflight, UI, Host und Worker
|
||||
|
||||
## Prüfungen
|
||||
|
||||
- Release-Manifest-Gate mit Manipulations- und Missing-File-Test
|
||||
- reales Overlay des vollständigen Release-Manifests auf eine Git-Baseline
|
||||
- Baseline-Erhalt ohne nicht deklarierte Löschungen
|
||||
- vollständige Vendoo-Gates in getrennten Blöcken
|
||||
- Datenbankmigrationen
|
||||
- SQLite-Handle-Cleanup
|
||||
- Module, Capabilities, Security, Identity, Katalog und Production Operations
|
||||
- Windows-PowerShell-Parservertrag
|
||||
- Windows-CI mit vollständigem `npm run verify:all`
|
||||
- manifestbasierter GitHub-Release-Builder
|
||||
|
||||
## Sicherheitsgrenze
|
||||
|
||||
Der Updater verändert `main`, Coolify und die Produktionsinstanz erst nach grünen lokalen Tests und grünen GitHub-Checks. `/app/data`, Datenbanken, Uploads, Secrets und Backups sind nicht Bestandteil des Release-Manifests.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Abnahmebericht – Vendoo Production Updater 3.2
|
||||
|
||||
## Anlass
|
||||
|
||||
Die Updater-2.x-Reihe verwendete eine destruktive Kopierstrategie: Die geklonte Git-Arbeitskopie wurde geleert und anschließend über Ausschlussregeln aus dem Paket neu befüllt. Dadurch konnten Quell- und Laufzeitdaten nicht zuverlässig unterschieden werden. Der konkrete Fehler war das fehlende `db/schema.sql` im Auslieferungspaket beziehungsweise in der vorbereiteten Arbeitskopie.
|
||||
|
||||
## Architekturkorrektur
|
||||
|
||||
Updater 3.2 ersetzt diesen Mechanismus vollständig:
|
||||
|
||||
- keine Leerung der geklonten Baseline,
|
||||
- SHA-256-Manifest aller Release-Dateien,
|
||||
- Pflichtdateien werden vor jedem Test geprüft,
|
||||
- Overlay statt Vollersetzung,
|
||||
- Löschungen ausschließlich explizit über `removedFiles`,
|
||||
- Hashprüfung vor und nach jedem Kopiervorgang,
|
||||
- Sitzungen aus 2.x werden verworfen,
|
||||
- separater v3-Release-Branch,
|
||||
- vollständiger Windows-Releasevertrag in GitHub Actions.
|
||||
|
||||
## Pflichtdateien
|
||||
|
||||
Der Release wird unter anderem blockiert, wenn eine dieser Dateien fehlt:
|
||||
|
||||
- `db/schema.sql`
|
||||
- `lib/db.mjs`
|
||||
- `server.mjs`
|
||||
- `bootstrap.mjs`
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- Updater Preflight, UI, Host und Worker
|
||||
|
||||
## Prüfungen
|
||||
|
||||
- Release-Manifest-Gate mit Manipulations- und Missing-File-Test
|
||||
- reales Overlay des vollständigen Release-Manifests auf eine Git-Baseline
|
||||
- Baseline-Erhalt ohne nicht deklarierte Löschungen
|
||||
- vollständige Vendoo-Gates in getrennten Blöcken
|
||||
- Datenbankmigrationen
|
||||
- SQLite-Handle-Cleanup
|
||||
- Module, Capabilities, Security, Identity, Katalog und Production Operations
|
||||
- Windows-PowerShell-Parservertrag
|
||||
- Windows-CI mit vollständigem `npm run verify:all`
|
||||
- manifestbasierter GitHub-Release-Builder
|
||||
|
||||
## Sicherheitsgrenze
|
||||
|
||||
Der Updater verändert `main`, Coolify und die Produktionsinstanz erst nach grünen lokalen Tests und grünen GitHub-Checks. `/app/data`, Datenbanken, Uploads, Secrets und Backups sind nicht Bestandteil des Release-Manifests.
|
||||
|
||||
## Korrektur 3.2
|
||||
|
||||
- `release-manifest.json` wird nach dem Payload als eigene Steuerdatei kopiert und bytegenau geprüft.
|
||||
- Das Zielrepository darf unveränderte Baseline-Dateien enthalten; manifestierte Dateien bleiben vollständig hashverbindlich.
|
||||
- Die Löschung der Steuerdatei über `removedFiles` ist verboten.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Abnahmebericht – Vendoo Production Updater 3.3
|
||||
|
||||
## Behobener Fehler
|
||||
|
||||
Der Windows-Lauf erreichte das letzte Gate, scheiterte aber im POSIX-Installer-Test. Ursache war eine nicht portable Annahme: Git Bash auf NTFS sollte nach `chmod 600` über `stat -c` exakt Modus `600` melden.
|
||||
|
||||
## Korrektur
|
||||
|
||||
Die POSIX-Rechteprüfung bleibt auf Linux/NAS/VPS strikt. Windows Git Bash prüft die Shell-Syntax; die funktionale Simulation, `.env`, Token, Compose-Aufrufe und Modus `0600` werden verpflichtend unter Linux geprüft. Fehlgeschlagene Assertions liefern vollständige Diagnoseausgaben.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Abnahmebericht – Vendoo Production Updater 3.4
|
||||
|
||||
**Status:** in technischer Abnahme
|
||||
**Branch:** `feature/updater-3.4-lifecycle`
|
||||
**Pull Request:** `#28`
|
||||
**Zielversion:** Vendoo `1.43.0`
|
||||
**Updater-Version:** `3.4.0`
|
||||
|
||||
## Verbindliche Ausgangslage
|
||||
|
||||
- `main` basiert auf dem gültigen Release aus PR #26.
|
||||
- PR #27 ist geschlossen, nicht gemergt und kein zulässiger Quellstand.
|
||||
- Der alte Worker 3.3 ist aus dem aktiven Release-Payload entfernt.
|
||||
- Persistente Produktionsdaten unter `/app/data` sind nicht Bestandteil des Payloads.
|
||||
- PR #28 bleibt bis zum vollständigen Linux-/Windows-Nachweis ein Draft.
|
||||
|
||||
## Ist-/Lückenanalyse Updater 3.3
|
||||
|
||||
| Bereich | Reale 3.3-Lücke | Umsetzung 3.4 | Nachweis |
|
||||
|---|---|---|---|
|
||||
| Check-Registrierung | `gh pr checks --watch` konnte bei noch nicht registrierten Checks sofort mit `no checks reported` abbrechen | eigener Check-Classifier mit 90 Sekunden Grace Period | Regressionstest und GitHub-CI ausstehend |
|
||||
| Manifest-Sync | Head-SHA konnte sich nach Start der Checkphase ändern | aktuelle `headRefOid` wird in jeder Runde gelesen; Wechsel startet Grace Period und Auswertung neu | Regressionstest und Integrationstest ausstehend |
|
||||
| Wiederholung nach Merge | 3.3 suchte nur offene PRs und konnte nach einem Merge erneut Sourcearbeit beginnen | gemergter passender PR führt direkt in Deployment/Verifikation | Regressionstest vorhanden, CI ausstehend |
|
||||
| `main` bereits Zielversion | 3.3 erzeugte weiterhin Arbeitskopie und Releasebranch | Sourcephase wird vollständig übersprungen | Regressionstest vorhanden, CI ausstehend |
|
||||
| Produktion bereits Zielversion | kein sofortiger erfolgreicher Zielzustand | sofortiger Erfolg ohne Branch, PR oder Deployment | Regressionstest vorhanden, reale Produktionsprobe ausstehend |
|
||||
| veralteter Payload | Overlay plus Force-Push konnte neuere `main`-Änderungen zurücksetzen | Provenienz v2, Base-Ancestor-Prüfung und Pfadkonflikt-Guard vor dem Overlay | realer temporärer Git-Regressionstest vorhanden, CI ausstehend |
|
||||
| Branch/PR-Idempotenz | Branch wurde stets neu erzeugt und unbedingt mit `--force` gepusht | deterministischer Branch, PR-Reconciliation und `--force-with-lease` | statischer Vertrag vorhanden, Windows-Lauf ausstehend |
|
||||
| Check-Lifecycle | keine konkrete Workflow-Run-Auswertung am finalen Head | Rollup plus Actions-Runs nach `head_sha`, neuester Run entscheidet | Regressionstests vorhanden, GitHub-Lauf ausstehend |
|
||||
| Merge-Sicherheit | kein atomarer Nachweis, dass der gemergte Head geprüft wurde | `--match-head-commit` mit unmittelbar zuvor bestätigtem finalen SHA | statischer Vertrag vorhanden, Integration ausstehend |
|
||||
|
||||
## Zustandsautomat
|
||||
|
||||
Der verbindliche Automat ist in `docs/PRODUCTION_UPDATER_3_4.md` dokumentiert. Die terminalen Zustände sind:
|
||||
|
||||
- `COMPLETED`: GitHub- und Produktionsziel nachgewiesen,
|
||||
- `FAILED`: terminaler Pflichtcheck, Payloadkonflikt, Timeout oder technischer Fehler,
|
||||
- `CANCELLED`: expliziter Benutzerabbruch mit erhaltenem Sitzungsstand.
|
||||
|
||||
Ein fehlender oder noch laufender Check ist kein terminaler Fehler.
|
||||
|
||||
## Regressionstest-Matrix
|
||||
|
||||
| Szenario | Erwartung | Automatisierter Test | Linux | Windows |
|
||||
|---|---|---:|---:|---:|
|
||||
| verspätet registrierte Checks | innerhalb Grace Period weiter warten | vorhanden | ausstehend | ausstehend |
|
||||
| `queued` / `pending` / `in_progress` | nicht fehlschlagen | vorhanden | ausstehend | ausstehend |
|
||||
| Manifest-Sync erzeugt neuen Head | alten Head verwerfen, neuen Head vollständig prüfen | Worker-/Lifecycle-Vertrag vorhanden | ausstehend | ausstehend |
|
||||
| bereits gemergter PR | keinen zweiten PR erzeugen | vorhanden | ausstehend | ausstehend |
|
||||
| bereits deployte Zielversion | sofort erfolgreich ohne Änderungen | vorhanden | ausstehend | ausstehend |
|
||||
| doppelter Start | vorhandenen Zustand übernehmen | Entscheidungs- und Sessionvertrag vorhanden | ausstehend | ausstehend |
|
||||
| parallele Workflow-Runs | zeitlich neuesten Run bewerten | vorhanden | ausstehend | ausstehend |
|
||||
| veralteter Payload gegen neueren `main` | vor Overlay blockieren | realer temporärer Git-Test vorhanden | ausstehend | ausstehend |
|
||||
| blockierter Rückschritt | Konfliktpfade ausgeben, keine Sourceänderung | vorhanden | ausstehend | ausstehend |
|
||||
| älterer Erfolg, neuerer Fehler | neuer Fehler ist terminal | vorhanden | ausstehend | ausstehend |
|
||||
| älterer Fehler, neuer Lauf | neuer Lauf bleibt pending | vorhanden | ausstehend | ausstehend |
|
||||
| älterer Abbruch, neuer Erfolg | neuer Erfolg gilt | vorhanden | ausstehend | ausstehend |
|
||||
|
||||
## Releasevertrag
|
||||
|
||||
Vor einer Freigabe müssen nachweislich erfolgreich sein:
|
||||
|
||||
1. Syntax- und Sicherheitsgates unter Linux,
|
||||
2. vollständiges `npm run verify:all` unter Windows,
|
||||
3. PowerShell-Parserprüfung von UI, Host und Worker34,
|
||||
4. Manifest-Reproduzierbarkeit,
|
||||
5. Release-Staging mit `vendoo-release-package-v2`,
|
||||
6. SHA-256-Prüfung aller Paketdateien,
|
||||
7. Nachweis, dass der alte Worker 3.3 nicht ausgeliefert wird,
|
||||
8. keine Änderung an Datenbank- oder `/app/data`-Inhalten,
|
||||
9. Review des finalen PR-Head-SHA.
|
||||
|
||||
## Noch offene Abnahmepunkte
|
||||
|
||||
- GitHub-CI für den finalen PR-Head muss vollständig grün sein.
|
||||
- Der Windows-Vertrag muss den echten PowerShell-Parser und alle bestehenden Tests erfolgreich durchlaufen.
|
||||
- Das auslieferbare Paket darf erst danach erzeugt und als Workflow-Artefakt übernommen werden.
|
||||
- Die öffentliche Produktionsantwort konnte in dieser Arbeitsumgebung noch nicht unabhängig verifiziert werden; sie ist daher kein bereits erbrachter Nachweis.
|
||||
|
||||
## Freigabeentscheidung
|
||||
|
||||
**Noch nicht freigegeben.**
|
||||
Es existiert noch kein als produktionsreif deklarierter Download. Der Bericht wird erst nach finaler CI-, Paket- und Produktionsabnahme auf „bestanden“ gesetzt.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Coolify-Update auf Vendoo 1.42.0
|
||||
|
||||
# Empfohlener automatischer Weg
|
||||
|
||||
Für den normalen Updatefall nur das Release-ZIP entpacken und `UPDATE_VENDOO.bat` doppelklicken. GitHub-Anmeldung und Coolify-Webhook werden nur beim ersten Start abgefragt. Alle nachfolgenden Schritte dieser Datei sind der manuelle Notfall- und Kontrollweg.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Repository: `Masterluke77/vendoo`
|
||||
- Produktionsbranch: `main`
|
||||
- Dockerfile-Build
|
||||
- interner Port: `8124`
|
||||
- Domain: `https://vendoo.flatlined.de`
|
||||
- persistentes Volume unverändert nach `/app/data`
|
||||
- genau eine Instanz
|
||||
- Rolling Updates aus
|
||||
- Auto Deploy aus
|
||||
- FLUX Studio aus
|
||||
|
||||
## Vor dem Merge
|
||||
|
||||
1. Releasebranch `release/1.42.0-production-operations` aus aktuellem `main` erstellen.
|
||||
2. Vollständigen Source-Stand übertragen; keine `.env`, Datenbank, Uploads, Backups, Logs, Master-Keys oder installierten Laufzeitmodule committen.
|
||||
3. `npm ci` und `npm run verify:all` ausführen.
|
||||
4. Draft-PR erstellen und GitHub Actions abwarten.
|
||||
5. Erst nach grünem CI und Review nach `main` mergen.
|
||||
|
||||
## Zusätzliche Coolify-Variablen
|
||||
|
||||
```env
|
||||
COOLIFY_DEPLOYMENT_STATUS_ENDPOINT=/api/v1/deployments/{deployment_id}
|
||||
VENDOO_DEPLOY_TIMEOUT_SECONDS=900
|
||||
VENDOO_DEPLOY_HEALTH_POLL_SECONDS=15
|
||||
VENDOO_BUILD_REVISION=<Git-Commit-SHA>
|
||||
VENDOO_COOLIFY_AUTO_DEPLOY=false
|
||||
```
|
||||
|
||||
Die vorhandenen Produktionswerte müssen erhalten bleiben:
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
PORT=8124
|
||||
VENDOO_BIND_HOST=0.0.0.0
|
||||
PUBLIC_BASE_URL=https://vendoo.flatlined.de
|
||||
VENDOO_ALLOWED_HOSTS=vendoo.flatlined.de
|
||||
VENDOO_ALLOWED_ORIGINS=https://vendoo.flatlined.de
|
||||
VENDOO_TRUST_PROXY=true
|
||||
VENDOO_COOKIE_SECURE=true
|
||||
VENDOO_COOKIE_SAMESITE=lax
|
||||
VENDOO_INITIAL_DISABLED_MODULES=vendoo.flux-studio
|
||||
LOCAL_IMAGE_ENABLED=false
|
||||
LOCAL_IMAGE_ALLOW_REMOTE=false
|
||||
VENDOO_GITHUB_REPOSITORY=Masterluke77/vendoo
|
||||
VENDOO_GITHUB_BRANCH=main
|
||||
VENDOO_UPDATE_CHANNEL=stable
|
||||
VENDOO_BACKUP_BEFORE_DEPLOY=true
|
||||
VENDOO_DEPLOY_BACKUP_UPLOADS=false
|
||||
```
|
||||
|
||||
## Coolify-Konfiguration prüfen
|
||||
|
||||
1. Coolify öffnen und Projekt **Vendoo → Production → Application** wählen.
|
||||
2. Source muss die private GitHub-App, Repository `Masterluke77/vendoo` und Branch `main` verwenden.
|
||||
3. Build Pack **Dockerfile**, Base Directory `/`, Dockerfile `/Dockerfile`.
|
||||
4. Port `8124`; kein öffentliches Host-Port-Mapping.
|
||||
5. Domain `https://vendoo.flatlined.de`; HTTPS erzwingen.
|
||||
6. Volume `vendoo-data` unverändert nach `/app/data`.
|
||||
7. Replikate `1`, Rolling Updates aus, Auto Deploy aus.
|
||||
8. Healthcheck `/readyz` mit erwartetem HTTP 200.
|
||||
9. Speichern, aber das Volume niemals neu anlegen oder löschen.
|
||||
|
||||
## Erstes kontrolliertes Deployment
|
||||
|
||||
1. Nach dem Merge Coolify noch nicht blind mehrfach starten.
|
||||
2. Bestehendes Vendoo öffnen: **Administration → System → Updates**.
|
||||
3. Produktionscheck ausführen und Fehler vor dem Deployment beheben.
|
||||
4. Coolify-Provider, Trigger-Modus, Resource UUID, Repository und `main` prüfen.
|
||||
5. Webhook oder API-Token nur über die Vendoo-Oberfläche in den verschlüsselten Secret Store eintragen.
|
||||
6. **Version prüfen**. Eine neuere Zielversion wird nur akzeptiert, wenn das freigegebene Manifest SemVer, Kanal, HTTPS-Paket-URL und SHA-256 erfüllt.
|
||||
7. Zielversion `1.42.0` eintragen.
|
||||
8. Vorab-Backup aktiviert lassen. Uploads nur bei ausreichendem Backup-Speicher einschließen.
|
||||
9. **Coolify-Deployment starten** wählen und exakt `DEPLOY` eingeben.
|
||||
10. Der Auftrag muss nacheinander Backup, Backup-Verifikation und Coolify-Trigger protokollieren.
|
||||
11. Im API-Modus wird der Coolify-Status gepollt. Im Webhook-Modus nach dem tatsächlichen Coolify-Abschluss im Verlauf **Externen Abschluss bestätigen** wählen und exakt `CONFIRM` eingeben.
|
||||
12. Erfolg darf erst erscheinen, wenn `/readyz` grün ist und die laufende Version `1.42.0` meldet.
|
||||
|
||||
## Abnahme nach Deployment
|
||||
|
||||
- `/readyz` liefert HTTP 200 und Version `1.42.0`.
|
||||
- Login funktioniert.
|
||||
- Bestehende Benutzer, Artikel und Uploads sind vorhanden.
|
||||
- SQLite-Integrität ist `ok`.
|
||||
- Letztes Vorab-Backup ist als verifiziert sichtbar.
|
||||
- Deployment-Verlauf zeigt `succeeded` und nachvollziehbare Events.
|
||||
- FLUX Studio bleibt deaktiviert.
|
||||
- Produktionscheck hat keine Fehler.
|
||||
|
||||
## Fehler und Rollback
|
||||
|
||||
Bei `rollback_required` nicht erneut deployen, bevor die Ursache geprüft wurde.
|
||||
|
||||
1. Coolify-Build- und Startlogs sichern.
|
||||
2. `/readyz`, Zielversion und Volume-Mount prüfen.
|
||||
3. In Coolify den letzten funktionierenden Commit beziehungsweise das vorige Image redeployen.
|
||||
4. Dasselbe Volume `/app/data` weiterverwenden.
|
||||
5. Volume niemals löschen oder neu initialisieren.
|
||||
6. Nach erfolgreichem Rollback Login, Artikel, Uploads, SQLite und `/readyz` prüfen.
|
||||
7. Den blockierten Vendoo-Auftrag erst danach mit **Prüfung abschließen** und exakt `CONFIRM` dokumentiert schließen.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Vendoo 1.42.0 – Ein-Klick-Update
|
||||
|
||||
## So benutzt du es
|
||||
|
||||
1. ZIP vollständig in einen neuen Ordner entpacken.
|
||||
2. `UPDATE_VENDOO.bat` doppelklicken.
|
||||
3. Nur beim ersten Start GitHub im Browser anmelden.
|
||||
4. Nur beim ersten Start den Coolify-Deploy-Webhook einfügen.
|
||||
5. Danach läuft alles automatisch.
|
||||
|
||||
## Automatischer Ablauf
|
||||
|
||||
- Git, Node.js LTS und GitHub CLI werden bei Bedarf über `winget` installiert.
|
||||
- Das private Repository `Masterluke77/vendoo` wird frisch aus `main` geklont.
|
||||
- Ein isolierter Release-Branch wird angelegt.
|
||||
- Nur sichere Source-Dateien werden übernommen.
|
||||
- `.env`, Datenbanken, Uploads, Backups, Logs und Schlüssel bleiben ausgeschlossen.
|
||||
- `npm ci` und `npm run verify:all` laufen vollständig.
|
||||
- Der Pull Request wird erstellt, geprüft und nach `main` gemergt.
|
||||
- GitHub Actions startet den Coolify-Deploy-Webhook.
|
||||
- Erfolg wird erst gemeldet, wenn `/readyz` HTTP 200 und exakt Version `1.42.0` liefert.
|
||||
- Abschließend wird `/healthz` geprüft.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
Der Coolify-Webhook wird nicht in das Paket oder Repository geschrieben. Er wird nur einmalig als verschlüsseltes GitHub Actions Secret `COOLIFY_DEPLOY_WEBHOOK_URL` gespeichert.
|
||||
|
||||
Das persistente Coolify-Volume `/app/data` wird weder ersetzt noch gelöscht. Parallele Produktionsdeployments werden durch GitHub Actions `concurrency` verhindert.
|
||||
|
||||
Lokale Protokolle liegen unter `logs/update-1.42.0-<Zeitstempel>.log`. Der Ordner `logs` wird vom Git-Staging ausgeschlossen.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Vendoo Production Updater 2.0
|
||||
|
||||
Der bisherige Hotfix-Updater wurde vollständig ersetzt.
|
||||
|
||||
## Eigenschaften
|
||||
|
||||
- moderne, nüchterne WPF-Oberfläche ohne dekorative Spielereien
|
||||
- Fenster bleibt bei Erfolg, Abbruch und Fehler geöffnet
|
||||
- sichtbare zehnstufige Prozessanzeige und technisches Live-Protokoll
|
||||
- verwaltete portable GitHub CLI statt Winget-Abhängigkeit
|
||||
- offizieller GitHub-Download mit SHA-256-Abgleich gegen die Release-Prüfsummen
|
||||
- feste Zeitlimits und kontrollierter Prozessabbruch inklusive Prozessbaum
|
||||
- sichere Coolify-Eingabe ohne Klartext im Log oder in der Kommandozeile
|
||||
- persistenter Wiederaufnahmepunkt unter `%LOCALAPPDATA%\Vendoo\Updater`
|
||||
- idempotenter Ablauf: nach Merge- oder Deploymentfehlern wird nicht blind neu begonnen
|
||||
- kein automatisches Schließen der Oberfläche
|
||||
|
||||
## Start
|
||||
|
||||
`UPDATE_VENDOO.bat` doppelklicken.
|
||||
|
||||
Eine GitHub-Browseranmeldung und die Eingabe des Coolify-Webhooks sind nur erforderlich, wenn sie auf diesem Rechner beziehungsweise im Repository noch fehlen.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Vendoo Production Updater 2.1
|
||||
|
||||
## Ziel
|
||||
|
||||
Production Updater 2.1 ersetzt den direkten, nicht ausreichend überwachten Worker-Start aus 2.0. Die Oberfläche besitzt jetzt bereits vor dem Worker-Start einen festen Laufzeitordner, einen bekannten Protokollpfad und einen initialen Status.
|
||||
|
||||
## Worker-Host
|
||||
|
||||
Die Oberfläche startet ausschließlich `Vendoo.Updater.Host.ps1`. Dieser kleine Bootstrap-Host:
|
||||
|
||||
- prüft alle Übergabepfade,
|
||||
- schreibt vor dem Laden des Update-Kerns einen Status,
|
||||
- fängt Lade-, Parser- und Initialisierungsfehler des Worker-Kerns ab,
|
||||
- schreibt die vollständige PowerShell-Ausnahme inklusive Datei und Zeile,
|
||||
- garantiert einen terminalen Status,
|
||||
- verliert weder Standardausgabe noch Standardfehler.
|
||||
|
||||
Der eigentliche Update-Kern läuft weiter in `Vendoo.Updater.Worker.ps1`, beendet aber nicht mehr eigenmächtig den PowerShell-Host. Erfolg, Abbruch und Fehler werden ausschließlich über den atomar geschriebenen Statusvertrag gemeldet.
|
||||
|
||||
## Diagnose
|
||||
|
||||
Jeder Lauf besitzt einen eigenen Ordner unter:
|
||||
|
||||
```text
|
||||
%LOCALAPPDATA%\Vendoo\Updater\runtime\<Zeitstempel>
|
||||
```
|
||||
|
||||
Darin liegen mindestens:
|
||||
|
||||
- `status.json`
|
||||
- `events.ndjson`
|
||||
- `updater.log`
|
||||
- bei einem Host-Absturz zusätzlich `worker.stdout.log` und `worker.stderr.log`
|
||||
|
||||
Die Oberfläche zeigt den Protokollnamen sofort an. Mit **Diagnoseordner** kann der vollständige Laufzeitordner geöffnet werden. Der Protokollpfad kann nicht mehr leer sein.
|
||||
|
||||
## Sicherheitsgrenzen
|
||||
|
||||
Die Änderungen betreffen ausschließlich den Updater-Unterbau. Vendoo 1.43.0, `/app/data`, Datenbanken, Uploads, Secrets und Coolify-Konfiguration werden dadurch nicht zurückgesetzt oder gelöscht.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Vendoo Production Updater 2.2
|
||||
|
||||
## Anlass
|
||||
|
||||
Updater 2.1 enthielt zwei echte Windows-PowerShell-Parserfehler. In doppelt quotierten Zeichenketten standen `"$Label: ..."` und `"$Title: ..."`. Windows PowerShell interpretiert den Doppelpunkt als Teil eines qualifizierten Variablenverweises. Korrekt sind `"${Label}: ..."` und `"${Title}: ..."`.
|
||||
|
||||
Der bisherige JavaScript-Vertrag prüfte nur das Vorhandensein bestimmter Textbausteine. Er war kein echter PowerShell-Parser und durfte deshalb nicht als solcher bezeichnet werden.
|
||||
|
||||
## Neue harte Gates
|
||||
|
||||
1. `UPDATE_VENDOO.bat` führt vor dem Start der Oberfläche synchron `Vendoo.Updater.Preflight.ps1` aus.
|
||||
2. Die Preflight-Prüfung verwendet den echter Windows-PowerShell-5.1-Parser über `System.Management.Automation.Language.Parser`.
|
||||
3. UI, Host, Worker und Launcher müssen vollständig parserfehlerfrei sein, bevor die Oberfläche startet.
|
||||
4. Der Worker-Host parst den Worker unmittelbar vor dem Laden erneut.
|
||||
5. `verify-powershell-static.mjs` blockiert die konkrete Variablen-Doppelpunkt-Fehlerklasse bereits auf Linux und während des Paketbaus.
|
||||
6. GitHub Actions besitzt einen eigenen `windows-latest`-Job mit echtem Windows-PowerShell-5.1-Parser.
|
||||
7. Release-Pakete hängen vom erfolgreichen Windows-Parser-Job ab.
|
||||
|
||||
## Fehlerverhalten
|
||||
|
||||
Schlägt die lokale Parserprüfung fehl, startet die Oberfläche nicht. Stattdessen bleiben ein sichtbarer Fehlerdialog, ein Konsolenhinweis und `logs/updater-preflight.log` erhalten. Damit kann kein syntaktisch defekter Worker mehr bis zum Worker-Host gelangen.
|
||||
|
||||
## Unveränderte Sicherheitsgrenzen
|
||||
|
||||
Vendoo 1.43.0, `/app/data`, Datenbanken, Uploads, Backups, Secrets und die Coolify-Konfiguration werden durch diese Änderung nicht zurückgesetzt oder gelöscht.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Vendoo Production Updater 2.3
|
||||
|
||||
## Pfadvertrag
|
||||
|
||||
Version 2.3 entfernt die fehleranfällige Übergabe von `%~dp0` als PowerShell-Parameter. Ein mit Backslash endender Batch-Pfad kann vor einem schließenden Anführungszeichen als Bestandteil des Arguments interpretiert werden und dadurch ein illegales `"` in den Pfad einschleusen.
|
||||
|
||||
Der Paketpfad wird nun ausschließlich aus `$PSScriptRoot` des ausgelieferten Preflight-Skripts abgeleitet, mit `Resolve-Path` kanonisiert und vor jeder Dateisystemoperation validiert. Oberfläche und Worker verwenden denselben Vertrag.
|
||||
|
||||
## Startverhalten
|
||||
|
||||
- `UPDATE_VENDOO.bat` verwendet `pushd` und relative Skriptpfade.
|
||||
- Es wird kein Paketroot mehr über die Kommandozeile übergeben.
|
||||
- Preflight und GUI laufen synchron; Exit-Codes bleiben sichtbar.
|
||||
- Bei Fehlern bleibt die Konsole geöffnet.
|
||||
- Das Preflight-Protokoll liegt unter `logs/updater-preflight.log`.
|
||||
|
||||
## Regression
|
||||
|
||||
Das Release-Gate verbietet `-PackageRoot "%~dp0"`, verlangt `param()` im Preflight und prüft die kanonische Root-Ableitung über `$PSScriptRoot`, `Resolve-Path` und `GetFullPath`.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Vendoo Production Updater 2.4
|
||||
|
||||
## Ziel
|
||||
|
||||
Version 2.4 ersetzt den unsichtbaren interaktiven GitHub-Login aus 2.3. Ein interaktiver Gerätefluss darf nicht in einem versteckten Worker laufen.
|
||||
|
||||
## GitHub-Anmeldung
|
||||
|
||||
Ist noch kein GitHub-Konto angemeldet, öffnet der Updater ein sichtbares Anmeldefenster. Dort zeigt GitHub CLI den einmaligen Gerätecode an, kopiert ihn mit `--clipboard` in die Zwischenablage und öffnet den Webbrowser. Die Vendoo-Oberfläche bleibt geöffnet und prüft den Status still im Hintergrund. Nach erfolgreicher Anmeldung wird automatisch fortgefahren.
|
||||
|
||||
Der Ablauf verwendet:
|
||||
|
||||
```text
|
||||
gh auth login --hostname github.com --git-protocol https --web --clipboard
|
||||
```
|
||||
|
||||
Die Anmeldung läuft nicht mehr über den versteckten Worker-Prozess. Ein Abbruch oder Fehler bleibt sichtbar und führt zu einem klaren, wiederholbaren Fehlerzustand.
|
||||
|
||||
## Downloadstatus
|
||||
|
||||
Der Download der portablen GitHub CLI schreibt nur noch sinnvolle 10-%-Fortschrittsstufen. Wiederholte Meldungen für dieselbe Megabyte-Grenze und die bisherige Logflut sind ausgeschlossen.
|
||||
|
||||
## Weiterhin gültige Schutzregeln
|
||||
|
||||
- echter Windows-PowerShell-5.1-Parser vor dem Start
|
||||
- kanonische Root-Pfadermittlung
|
||||
- persistenter Laufzeit- und Diagnoseordner
|
||||
- keine Änderung an `main` vor grünen Tests
|
||||
- kein Produktions-Erfolg ohne exakte Zielversion
|
||||
- kein Zugriff auf oder Löschen von `/app/data`
|
||||
@@ -0,0 +1,20 @@
|
||||
# Vendoo Production Updater 2.5
|
||||
|
||||
## Zweck
|
||||
|
||||
Version 2.5 ersetzt den fehlerhaft ausgelieferten CMD-Launcher aus 2.4. Die PowerShell- und Vendoo-Logik bleibt unverändert; korrigiert wurde der Windows-Einstiegspunkt.
|
||||
|
||||
## Verbindlicher Launcher-Vertrag
|
||||
|
||||
- `UPDATE_VENDOO.bat` ist reines 7-Bit-ASCII.
|
||||
- Die Datei besitzt keine BOM.
|
||||
- Jede Zeile endet mit Windows-CRLF.
|
||||
- Der Launcher enthält keine Umlaute und keinen `chcp`-Wechsel.
|
||||
- Windows PowerShell wird über den absoluten Systempfad gestartet.
|
||||
- Fehlerzustände bleiben mit `pause` sichtbar.
|
||||
- `.gitattributes` und `.editorconfig` erzwingen CRLF für BAT/CMD.
|
||||
- Das Binär-Gate prüft die tatsächlichen Bytes und nicht nur gelesenen Text.
|
||||
|
||||
## Ursache des 2.4-Fehlers
|
||||
|
||||
Die BAT war UTF-8 mit Unix-LF-Zeilenenden. `cmd.exe` interpretierte dadurch mehrere Zeilen beschädigt und verlor sichtbare Anfangszeichen von Befehlen.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Vendoo Production Updater 2.6
|
||||
|
||||
## Ziel
|
||||
|
||||
Updater 2.6 entfernt die unnötige Coolify-Webhook-Abfrage vollständig.
|
||||
|
||||
Nach dem kontrollierten Merge nach `main` nutzt Vendoo die bereits bestehende GitHub-Verknüpfung von Coolify und wartet auf das automatische Deployment. Der Updater prüft anschließend `/readyz` und akzeptiert den Lauf nur, wenn exakt Vendoo 1.43.0 gemeldet wird.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Die Vendoo-Anwendung ist in Coolify mit `Masterluke77/vendoo` und Branch `main` verbunden.
|
||||
- In Coolify ist für die Anwendung **Auto Deploy** aktiviert.
|
||||
- `https://vendoo.flatlined.de/readyz` ist öffentlich erreichbar.
|
||||
|
||||
## Verhalten
|
||||
|
||||
- keine Eingabe eines Coolify-Webhooks
|
||||
- kein GitHub Secret `COOLIFY_DEPLOY_WEBHOOK_URL`
|
||||
- kein manuell gestarteter Deployment-Workflow
|
||||
- Wartezeit maximal 15 Minuten
|
||||
- Statusmeldung höchstens alle 30 Sekunden
|
||||
- eindeutiger Hinweis auf Auto Deploy, falls die Zielversion nicht erscheint
|
||||
@@ -0,0 +1,18 @@
|
||||
# Vendoo Production Updater 2.8
|
||||
|
||||
## Ziel
|
||||
|
||||
Updater 2.8 behebt einen Windows-spezifischen SQLite-Lifecycle-Fehler im vollständigen Release-Test.
|
||||
|
||||
`verify-initial-disabled-modules-1.41.2.mjs` lud den Plattformkernel und öffnete dadurch `better-sqlite3`, löschte den temporären Datenordner anschließend aber ohne die Datenbank explizit zu schließen. Linux tolerierte das; Windows blockierte `vendoo.db` mit `EBUSY`.
|
||||
|
||||
## Neuer Ressourcenvertrag
|
||||
|
||||
- explizites `wal_checkpoint(TRUNCATE)`
|
||||
- explizites `db.close()` vor jeder Temp-Bereinigung
|
||||
- kurze Freigabephase nach dem synchronen Close
|
||||
- `rmSync` mit 20 Wiederholungen und 100 ms Wartezeit für kurzzeitige Windows-Dateisperren
|
||||
- Cleanup-Fehler werden mit Pfad und Fehlercode gemeldet
|
||||
- drei isolierte Wiederholungsläufe prüfen, dass kein Temp-Ordner zurückbleibt
|
||||
|
||||
Der Fehler tritt vor Pull Request, Merge und Coolify-Deployment auf. Produktivdaten waren dadurch nicht betroffen.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Vendoo Production Updater 2.9
|
||||
|
||||
Updater 2.9 behebt die Windows-Dateisperren in allen aktiven Verifiern systematisch.
|
||||
|
||||
## Änderungen
|
||||
|
||||
- Zentraler SQLite-Test-Cleanup mit WAL-Checkpoint, `db.close()` und Windows-tauglichen Löschwiederholungen.
|
||||
- Einladungs-, Initialmodul-, Capability- und Architekturtest verwenden denselben Cleanup-Vertrag.
|
||||
- Statischer Importgraph-Audit prüft alle aktiven `verify:all`-Verifier auf transitive `lib/db.mjs`-Nutzung.
|
||||
- Dynamische Regression führt alle vier datenbanknahen Testklassen jeweils dreimal in getrennten Node-Prozessen aus.
|
||||
- Kein Pull Request, Merge oder Deployment bei fehlgeschlagenem Testlauf.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Vendoo Production Updater 3.1
|
||||
|
||||
## Architekturwechsel
|
||||
|
||||
Updater 3.1 verwendet keine Ausschlusslisten-Kopie und leert die geklonte `main`-Arbeitskopie nicht mehr.
|
||||
|
||||
Der Release enthält `release-manifest.json` mit:
|
||||
|
||||
- jeder auszuliefernden Quelldatei,
|
||||
- Dateigröße,
|
||||
- SHA-256-Prüfsumme,
|
||||
- verbindlichen Pflichtdateien,
|
||||
- ausschließlich expliziten Löschungen in `removedFiles`.
|
||||
|
||||
## Anwendung
|
||||
|
||||
1. `main` wird in einen isolierten Arbeitsordner geklont.
|
||||
2. Das gesamte Paket wird gegen `release-manifest.json` geprüft.
|
||||
3. Sichere Paketdateien, die nicht im Manifest stehen, blockieren das Update.
|
||||
4. Manipulierte oder fehlende Dateien blockieren das Update.
|
||||
5. Der Release wird als Overlay angewendet; bestehende Baseline-Dateien bleiben erhalten.
|
||||
6. Nur Einträge in `removedFiles` werden gelöscht.
|
||||
7. Jede kopierte Datei wird im Ziel erneut per SHA-256 geprüft.
|
||||
8. Erst danach laufen `npm ci` und `npm run verify:all`.
|
||||
|
||||
## Pflichtdateien
|
||||
|
||||
Unter anderem müssen immer vorhanden sein:
|
||||
|
||||
- `db/schema.sql`
|
||||
- `lib/db.mjs`
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- `server.mjs`
|
||||
- `bootstrap.mjs`
|
||||
- Updater-Preflight, UI, Host und Worker
|
||||
|
||||
## Sitzungen
|
||||
|
||||
Sitzungsstände der Updater-2.x-Reihe werden nicht übernommen. Updater 3.1 beginnt mit einem neuen Release-Branch und einer neuen, versionsgebundenen Sitzung.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Vendoo Production Updater 3.2
|
||||
|
||||
## Architekturwechsel
|
||||
|
||||
Updater 3.2 verwendet keine Ausschlusslisten-Kopie und leert die geklonte `main`-Arbeitskopie nicht mehr.
|
||||
|
||||
Der Release enthält `release-manifest.json` mit:
|
||||
|
||||
- jeder auszuliefernden Quelldatei,
|
||||
- Dateigröße,
|
||||
- SHA-256-Prüfsumme,
|
||||
- verbindlichen Pflichtdateien,
|
||||
- ausschließlich expliziten Löschungen in `removedFiles`.
|
||||
|
||||
## Anwendung
|
||||
|
||||
1. `main` wird in einen isolierten Arbeitsordner geklont.
|
||||
2. Das gesamte Paket wird gegen `release-manifest.json` geprüft.
|
||||
3. Sichere Paketdateien, die nicht im Manifest stehen, blockieren das Update.
|
||||
4. Manipulierte oder fehlende Dateien blockieren das Update.
|
||||
5. Der Release wird als Overlay angewendet; bestehende Baseline-Dateien bleiben erhalten.
|
||||
6. Nur Einträge in `removedFiles` werden gelöscht.
|
||||
7. Jede kopierte Datei wird im Ziel erneut per SHA-256 geprüft.
|
||||
8. Erst danach laufen `npm ci` und `npm run verify:all`.
|
||||
|
||||
## Pflichtdateien
|
||||
|
||||
Unter anderem müssen immer vorhanden sein:
|
||||
|
||||
- `db/schema.sql`
|
||||
- `lib/db.mjs`
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- `server.mjs`
|
||||
- `bootstrap.mjs`
|
||||
- Updater-Preflight, UI, Host und Worker
|
||||
|
||||
## Sitzungen
|
||||
|
||||
Sitzungsstände der Updater-2.x-Reihe werden nicht übernommen. Updater 3.2 beginnt mit einem neuen Release-Branch und einer neuen, versionsgebundenen Sitzung.
|
||||
|
||||
## Steuerdatei-Vertrag 3.2
|
||||
|
||||
`release-manifest.json` ist kein selbstgehashter Payload-Eintrag. Der Applier kopiert es nach erfolgreicher Payload-Prüfung als separates Steuerartefakt bytegenau in die Git-Arbeitskopie. Verifier in einer Git-Baseline dürfen zusätzliche unveränderte Dateien sehen, müssen aber jede manifestierte Datei und ihre SHA-256-Prüfsumme bestätigen.
|
||||
|
||||
## Fehlerdarstellung
|
||||
|
||||
Bei fehlgeschlagenen Prozessen priorisiert der Worker `stderr` als **Fehlerausgabe** und zeigt `stdout` nur gekürzt als ergänzende **letzte Standardausgabe**. Dadurch bleibt die eigentliche Ursache im Statusfenster sichtbar.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Vendoo Production Updater 3.3
|
||||
|
||||
## Zweck
|
||||
|
||||
Updater 3.3 behält das manifestbasierte Overlay von 3.2 bei und korrigiert das letzte plattformspezifische Release-Gate.
|
||||
|
||||
## Installer-Shell-Portabilität
|
||||
|
||||
- Unter Linux, NAS und VPS muss `.env` nach der Erzeugung weiterhin exakt POSIX-Modus `0600` besitzen.
|
||||
- Unter Windows prüft Git Bash ausschließlich die Syntax von `setup.sh` und dem funktionalen Shell-Test. Die vollständige Docker-/NAS-/VPS-Simulation läuft im verpflichtenden Linux-CI-Gate, wo POSIX-Rechte real abgebildet werden.
|
||||
- Der Shell-Test erkennt MINGW, MSYS und CYGWIN ausdrücklich.
|
||||
- Jede fehlgeschlagene Assertion zeigt die betroffene Ausgabe- oder Konfigurationsdatei vollständig an.
|
||||
- Der Node-Wrapper reicht `stdout` und `stderr` im Fehlertext weiter.
|
||||
|
||||
## Unveränderte Sicherheitsgrenzen
|
||||
|
||||
Das SHA-256-Manifest, die bytegeprüfte Steuerdatei, explizite Löschungen, GitHub-PR-/Merge-Gates, Coolify Auto Deploy und `/readyz` bleiben verpflichtend.
|
||||
@@ -0,0 +1,189 @@
|
||||
# Vendoo Production Updater 3.4
|
||||
|
||||
## Zweck
|
||||
|
||||
Updater 3.4 ersetzt den linearen, branchorientierten Ablauf von Updater 3.3 durch einen idempotenten Release-Lifecycle. Der Updater arbeitet auf einen nachprüfbaren Zielzustand hin und erzeugt keine weiteren Quelländerungen, sobald `main` oder die Produktion die Zielversion bereits erreicht haben.
|
||||
|
||||
Der Vertrag schützt insbesondere:
|
||||
|
||||
- den aktuellen Stand von `main` vor veralteten Payloads,
|
||||
- den Pull Request vor Merge eines nicht mehr geprüften Head-SHA,
|
||||
- vorhandene Releases vor doppelten Branches und Pull Requests,
|
||||
- persistente Produktionsdaten unter `/app/data`,
|
||||
- Datenbanken, Uploads, Benutzer, Artikel, Einstellungen, API-Schlüssel, FLUX-Modelle und Backups.
|
||||
|
||||
## Quellwahrheit
|
||||
|
||||
Der Updater wertet den Zustand in dieser Reihenfolge aus:
|
||||
|
||||
1. aktueller GitHub-Branch `main`,
|
||||
2. aktueller Pull-Request-Head und konkrete GitHub-Workflow-Runs,
|
||||
3. öffentliche Produktionsantwort `/readyz`,
|
||||
4. lokaler Sitzungsstand nur als Wiederaufnahmekontext.
|
||||
|
||||
Ein lokaler Sitzungsstand darf niemals einen neueren GitHub- oder Produktionszustand überschreiben.
|
||||
|
||||
## Zustandsautomat
|
||||
|
||||
```text
|
||||
PACKAGE_VALIDATED
|
||||
-> TOOLS_READY
|
||||
-> GITHUB_READY
|
||||
-> TARGET_OBSERVED
|
||||
|
||||
TARGET_OBSERVED
|
||||
-> COMPLETED
|
||||
wenn main == target und production == target
|
||||
-> DEPLOY_REQUIRED
|
||||
wenn main == target und production != target
|
||||
-> PR_RESUME
|
||||
wenn ein passender offener PR existiert
|
||||
-> DEPLOY_REQUIRED
|
||||
wenn ein passender PR bereits gemergt ist
|
||||
-> SOURCE_REQUIRED
|
||||
nur wenn main noch nicht target ist und kein passender PR existiert
|
||||
|
||||
SOURCE_REQUIRED
|
||||
-> PAYLOAD_GUARD
|
||||
-> LOCAL_CONTRACT
|
||||
-> TARGET_REOBSERVED
|
||||
-> PR_RECONCILED
|
||||
|
||||
PR_RESUME | PR_RECONCILED
|
||||
-> CHECKS_TRACKING
|
||||
|
||||
CHECKS_TRACKING
|
||||
-> CHECKS_TRACKING
|
||||
wenn sich der PR-Head-SHA ändert; Grace Period beginnt erneut
|
||||
-> CHECKS_TRACKING
|
||||
wenn Checks noch nicht registriert, queued, pending oder in_progress sind
|
||||
-> MERGE_READY
|
||||
wenn alle Pflichtchecks und Pflichtworkflows am stabilen finalen Head erfolgreich sind
|
||||
-> FAILED
|
||||
bei terminalem Pflichtfehler, Abbruch oder Gesamtzeitüberschreitung
|
||||
|
||||
MERGE_READY
|
||||
-> MERGED
|
||||
nur per --match-head-commit für den geprüften Head-SHA
|
||||
-> DEPLOY_REQUIRED
|
||||
-> PRODUCTION_VERIFIED
|
||||
-> COMPLETED
|
||||
```
|
||||
|
||||
## Idempotenzregeln
|
||||
|
||||
### Bereits aktuelle Produktion
|
||||
|
||||
Wenn `main` und `/readyz` bereits die Zielversion melden, endet der Updater sofort erfolgreich. Es werden weder Arbeitskopie noch Branch, Pull Request, Commit oder Deployment erzeugt.
|
||||
|
||||
### `main` bereits auf Zielversion
|
||||
|
||||
Wenn `main` bereits die Zielversion enthält, wird die Sourcephase vollständig übersprungen. Der Updater prüft nur noch, ob die Produktion nachziehen muss.
|
||||
|
||||
### Bereits gemergter Pull Request
|
||||
|
||||
Ein bereits gemergter passender Pull Request wird als abgeschlossene Sourcephase behandelt. Der Updater erzeugt keinen zweiten Pull Request und setzt beim Deployment beziehungsweise bei der Produktionsprüfung fort.
|
||||
|
||||
### Bereits offener Pull Request
|
||||
|
||||
Ein vorhandener offener Pull Request des deterministischen Releasebranches wird übernommen. Sein aktueller `headRefOid` ist die alleinige Prüfreferenz.
|
||||
|
||||
### Doppelter oder paralleler Start
|
||||
|
||||
Die Desktopoberfläche verhindert parallele lokale Prozesse per Mutex. Zusätzlich gleicht jeder Lauf GitHub und Produktion erneut ab. Ein konkurrierender Lauf, der zwischen lokaler Prüfung und Push einen Pull Request erzeugt oder `main` aktualisiert, wird bei der zweiten Zielbeobachtung erkannt und übernommen.
|
||||
|
||||
## Paket-Provenienz
|
||||
|
||||
Auslieferbare Pakete verwenden das Schema `vendoo-release-package-v2` und enthalten:
|
||||
|
||||
- `targetVersion`,
|
||||
- `updaterVersion`,
|
||||
- `sourceCommit`,
|
||||
- `baseCommit`,
|
||||
- `createdAt`,
|
||||
- `payloadHash`,
|
||||
- die vollständige SHA-256-Dateiliste,
|
||||
- explizite Löschungen.
|
||||
|
||||
`payloadHash` wird aus den stabilen Manifestfeldern berechnet. Jede Änderung an Herkunft, Zieldateien oder Löschungen verändert die Paketidentität.
|
||||
|
||||
Vor dem Overlay wird geprüft:
|
||||
|
||||
1. `baseCommit` und aktueller `main` müssen lokal auflösbar sein.
|
||||
2. `baseCommit` muss Vorfahr des aktuellen `main` sein.
|
||||
3. Alle Pfade, die Payload und `main` seit `baseCommit` beide verändert haben, werden verglichen.
|
||||
4. Weicht der gewünschte Payloadstand vom aktuellen `main` ab, wird der Lauf vor jeder Quelländerung blockiert.
|
||||
5. Nicht kollidierende Fortschritte in `main` dürfen bestehen bleiben.
|
||||
|
||||
Damit kann ein altes Paket neuere Änderungen aus `main` weder still noch per Force-Push zurücksetzen.
|
||||
|
||||
## GitHub-Check-Lifecycle
|
||||
|
||||
Updater 3.4 verwendet nicht mehr `gh pr checks --watch` als Blackbox.
|
||||
|
||||
Für jeden aktuellen PR-Head werden ausgewertet:
|
||||
|
||||
- `statusCheckRollup` des Pull Requests,
|
||||
- Workflow-Runs aus `actions/runs?head_sha=<finaler-head>&per_page=100`,
|
||||
- die Pflichtchecks `Syntax, Sicherheit und Release-Gates`, `Windows – vollständiger Releasevertrag` und `Updater-3.4-Paket nach Abnahme`,
|
||||
- die Pflichtworkflows `Vendoo CI` und `Vendoo Release Manifest Sync`.
|
||||
|
||||
Regeln:
|
||||
|
||||
- `no checks reported` ist während der 90-sekündigen Registrierungs-Grace-Period kein Fehler.
|
||||
- `queued`, `pending`, `in_progress`, `waiting` und `requested` sind laufende Zustände.
|
||||
- Bei mehreren Runs desselben Workflows entscheidet der zeitlich neueste Run; ein alter Erfolg darf einen neueren Fehler nicht verdecken.
|
||||
- Ändert der Manifest-Sync den Head-SHA, werden Grace Period und Checkauswertung für den neuen Head vollständig neu gestartet.
|
||||
- Vor dem Merge wird der Head-SHA erneut gelesen.
|
||||
- Der Merge verwendet `--match-head-commit`; ein weiterer Headwechsel verhindert den Merge atomar.
|
||||
|
||||
### Final-Head-Registrierung nach Manifest-Sync
|
||||
|
||||
Ein Push mit dem GitHub-Workflow-Token startet für den dadurch erzeugten Commit nicht automatisch einen weiteren Push-Workflow. Deshalb bestätigt der Manifest-Sync nach seinem Commit zuerst den tatsächlichen Remote-Head und dispatcht anschließend explizit:
|
||||
|
||||
1. `Vendoo CI` für den neuen Head,
|
||||
2. `Vendoo Release Manifest Sync` für denselben Head.
|
||||
|
||||
Der zweite Manifest-Sync-Lauf findet ein unverändertes Manifest, erzeugt keinen weiteren Commit und dient als konkreter erfolgreicher Workflow-Nachweis am finalen SHA. Die Concurrency des Sync-Workflows darf den auslösenden Lauf nicht abbrechen.
|
||||
|
||||
### Paket erst nach Plattformabnahme
|
||||
|
||||
Das auslieferbare ZIP entsteht im Job `Updater-3.4-Paket nach Abnahme`. Dieser Job besitzt eine harte `needs`-Abhängigkeit auf Linux- und Windows-Abnahme. Er läuft daher erst, wenn beide Verträge erfolgreich abgeschlossen wurden. Danach werden Root-Manifest, Paketmanifest v2, Zielversion, Updater-Version und ZIP-SHA-256 erneut geprüft. Nur dieses Artefakt ist ein auslieferbarer Kandidat.
|
||||
|
||||
## Sitzungsvertrag
|
||||
|
||||
Sitzungen verwenden `vendoo-updater-session-v2`. Die Identität besteht aus:
|
||||
|
||||
- Repository,
|
||||
- Zielversion,
|
||||
- Payload-Hash,
|
||||
- deterministischem Releasebranch.
|
||||
|
||||
Gespeichert werden unter anderem Pull-Request-Nummer und URL, finaler geprüfter Head-SHA, Merge-Commit, Produktionsversion, Releasezustand und Abschlusszeitpunkt. Der Sitzungsstand dient ausschließlich der Wiederaufnahme; jeder Lauf validiert die externen Zustände erneut.
|
||||
|
||||
## Branch- und Push-Sicherheit
|
||||
|
||||
- Releasebranch: `release/<zielversion>-production-update-v4`
|
||||
- kein unbedingtes `git push --force`
|
||||
- vorhandene Branches werden nur mit `--force-with-lease=<erwarteter-remote-sha>` aktualisiert
|
||||
- Merge ausschließlich für den nachgewiesenen finalen Head-SHA
|
||||
- kein Quell-PR, wenn `main` bereits auf Zielversion ist
|
||||
|
||||
## Produktionsvertrag
|
||||
|
||||
Das Deployment verändert keine persistenten Nutzdaten. `/app/data` bleibt außerhalb des Release-Payloads. Der Updater akzeptiert Erfolg erst, wenn `/readyz` sowohl `ok: true` als auch exakt die Zielversion meldet.
|
||||
|
||||
Die Option `-SkipProductionDeploy` ist ausschließlich für Source-/CI-Abnahmen vorgesehen. Sie darf nicht als Nachweis eines vollständigen Produktionsupdates ausgegeben werden.
|
||||
|
||||
## Fehlerdiagnose
|
||||
|
||||
Bei Fehler oder Timeout werden protokolliert:
|
||||
|
||||
- aktuelle Phase,
|
||||
- aktueller PR und Head-SHA,
|
||||
- Pflichtcheck- und Workflow-Matrix,
|
||||
- Prozessausgabe und Exit-Code,
|
||||
- Payload-Konfliktpfade,
|
||||
- konkrete Wiederanlaufempfehlung.
|
||||
|
||||
Ein erneuter Start beginnt nicht blind von vorn, sondern beobachtet Zielzustand, Pull Requests, Merge und Produktion erneut und setzt beim tatsächlich offenen Zustand fort.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Vendoo 1.42.0 – Production Operations & Controlled Update Completion
|
||||
|
||||
## Ziel
|
||||
|
||||
Version 1.42.0 vervollständigt den sicheren Coolify-Produktionsbetrieb. Ein HTTP-Erfolg des Deploy-Triggers gilt nicht mehr als erfolgreiches Deployment. Ein Auftrag durchläuft persistent gespeicherte Zustände und wird erst nach bestätigtem Deployment, erfolgreicher Readiness und passender Zielversion abgeschlossen.
|
||||
|
||||
## Neue Betriebsfunktionen
|
||||
|
||||
- Produktionsstatus mit Version, Build-Revision, Provider, öffentlicher URL, Readiness, SQLite-Status, Speicherbelegung, letztem verifiziertem Backup, letztem Deployment und FLUX-Status.
|
||||
- Produktions-Selbstprüfung für HTTPS, Cookies, Trust Proxy, Host/Origin, Persistenz, Schreibrechte, SQLite, Backupziel, FLUX, Auto Deploy und Coolify-Secrets.
|
||||
- Persistente Deployment-State-Machine mit Ereignisverlauf.
|
||||
- Idempotency-Key und Sperre gegen parallele Aufträge.
|
||||
- Verifiziertes Vorab-Backup vor dem Coolify-Trigger.
|
||||
- Coolify-API-Statusabfrage oder kontrollierter Webhook-Modus mit exakter Bestätigung `CONFIRM`.
|
||||
- `/readyz`- und Zielversionsprüfung vor dem Status `succeeded`.
|
||||
- `rollback_required` bei Timeout oder Coolify-Fehler; neue Aufträge bleiben bis zur dokumentierten Prüfung gesperrt.
|
||||
- Einladungsübersicht, 48-Stunden-Ablauf, erneutes Senden und Widerruf; Standardrolle ist `viewer`.
|
||||
|
||||
## Additive Datenbankmigration
|
||||
|
||||
Neu angelegt werden ausschließlich:
|
||||
|
||||
- `deployment_runs`
|
||||
- `deployment_events`
|
||||
- `backup_verifications`
|
||||
- `production_checks`
|
||||
|
||||
Bestehende Tabellen, Nutzer, Artikel, Uploads, Einstellungen, Secrets, Backups und Module werden nicht gelöscht oder zurückgesetzt.
|
||||
|
||||
## Neue administrative API-Verträge
|
||||
|
||||
- `GET /api/admin/deployment`
|
||||
- `PUT /api/admin/deployment`
|
||||
- `POST /api/admin/deployment/test`
|
||||
- `POST /api/admin/deployment/updates/check`
|
||||
- `POST /api/admin/deployment/trigger`
|
||||
- `GET /api/admin/deployment/runs`
|
||||
- `GET /api/admin/deployment/runs/:id`
|
||||
- `POST /api/admin/deployment/runs/:id/refresh`
|
||||
- `POST /api/admin/deployment/runs/:id/confirm`
|
||||
- `GET|POST /api/admin/deployment/production-check`
|
||||
- `GET /api/admin/users/invitations`
|
||||
- `POST /api/admin/users/invitations/:id/resend`
|
||||
- `DELETE /api/admin/users/invitations/:id`
|
||||
|
||||
Alle schreibenden Routen bleiben CSRF-geschützt, rollenbasiert und auditierbar. Secretwerte werden nicht zurückgegeben.
|
||||
|
||||
## Sicherheitsgrenzen
|
||||
|
||||
Vendoo 1.42.0 besitzt weiterhin keinen Docker-Socket und führt im Produktivcontainer weder `git pull`, `npm install`, Docker-Kommandos noch eine Selbstüberschreibung aus. Coolify bleibt für Build, Containerwechsel und Rollback verantwortlich.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Vendoo 1.43.0
|
||||
|
||||
## Schwerpunkt
|
||||
|
||||
- Modulstatus und Navigation sind verbindlich gekoppelt.
|
||||
- Das optionale Produktbild Studio ist standardmäßig deaktiviert.
|
||||
- Der Listing-Generator besitzt eine dauerhaft sichtbare Aktionsleiste.
|
||||
- `UPDATE_VENDOO.bat` startet eine grafische Statusanzeige mit Live-Protokoll.
|
||||
|
||||
## Sicherheitsgrenzen
|
||||
|
||||
Der Updater verändert weiterhin keine produktiven Daten unter `/app/data`, überträgt keine `.env`-Dateien und meldet erst nach erfolgreicher `/readyz`-Zielversionsprüfung Erfolg.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Vendoo 1.42.0 – Restore- und Notfall-Runbook
|
||||
|
||||
## Grundsatz
|
||||
|
||||
Ein Restore ist keine normale Weboperation. Das laufende Vendoo darf seine geöffnete SQLite-Datenbank nicht im Hintergrund überschreiben. Die Weboberfläche prüft und staged ein Backup; das tatsächliche Einspielen erfolgt im Wartungsfenster bei gestoppter Anwendung.
|
||||
|
||||
## Restore-Dry-Run
|
||||
|
||||
1. **Administration → System → Backup & Restore** öffnen.
|
||||
2. Gewünschtes Backup zuerst mit **Prüfen** validieren.
|
||||
3. SHA-256, Manifest, Dateigröße und SQLite-Integrität müssen stimmen.
|
||||
4. Für ein extern bereitgestelltes ZIP **Wiederherstellung prüfen und vorbereiten** verwenden.
|
||||
5. Vendoo erstellt vor dem Staging automatisch ein zusätzliches Sicherheitsbackup.
|
||||
6. Fehlerhafte, unvollständige oder verbotene Pakete nicht manuell entpacken.
|
||||
|
||||
## Wartungsrestore
|
||||
|
||||
1. Wartungsfenster ankündigen und Benutzer abmelden.
|
||||
2. Coolify-Anwendung stoppen; keine zweite Instanz starten.
|
||||
3. Vollständiges Volume `/app/data` zusätzlich auf Host-/NAS-Ebene sichern.
|
||||
4. Besonders zusammen sichern:
|
||||
- `/app/data/db/vendoo.db` samt möglicher `-wal`/`-shm`-Dateien
|
||||
- `/app/data/uploads`
|
||||
- `/app/data/config/master.key`
|
||||
- `/app/data/config/secrets.enc.json`
|
||||
- `/app/data/config/vendoo.env`
|
||||
- `/app/data/config/modules-state.json`
|
||||
5. Nur das verifizierte, vorbereitete Restore anwenden.
|
||||
6. Eigentümer und Schreibrechte des nicht privilegierten Containers erhalten.
|
||||
7. Anwendung mit exakt einer Instanz starten.
|
||||
8. `/readyz`, SQLite-Integrität, Login, Benutzer, Artikel, Uploads, Module und Audit prüfen.
|
||||
|
||||
## Reiner Anwendungsrollback
|
||||
|
||||
Bei fehlerhaftem Code-Release normalerweise keinen Datenrestore durchführen. In Coolify die vorherige Revision beziehungsweise das vorherige Image redeployen und dasselbe `/app/data`-Volume verwenden.
|
||||
|
||||
## Verboten
|
||||
|
||||
- `/app/data` löschen
|
||||
- neues leeres Volume anhängen
|
||||
- zwei schreibende Container auf demselben SQLite-Volume starten
|
||||
- die Datenbank bei laufendem Vendoo ersetzen
|
||||
- `master.key` neu erzeugen, solange bestehende verschlüsselte Secrets benötigt werden
|
||||
- ungeprüfte ZIP-Dateien direkt in das Volume kopieren
|
||||
@@ -0,0 +1,14 @@
|
||||
# Vendoo 1.43.0 – Updater-Hotfix H1
|
||||
|
||||
## Behoben
|
||||
|
||||
- Endloses Warten direkt nach „Node.js LTS gefunden“.
|
||||
- Falsche Erkennung des Windows-App-Execution-Alias als echte Node.js-Installation.
|
||||
- Node.js-Versionsprüfung besitzt jetzt ein hartes Timeout von 20 Sekunden.
|
||||
- Eine blockierte oder defekte Node.js-LTS-Installation wird über Winget automatisch repariert.
|
||||
- Der grafische Updater zeigt Laufzeit und Zeit seit der letzten Statusmeldung.
|
||||
- PowerShell- und Prozessausgabe verwenden konsistent UTF-8; deutsche Umlaute werden korrekt dargestellt.
|
||||
|
||||
## Sicherheitsgrenze
|
||||
|
||||
Bis zum Abschluss der lokalen Prüfungen wird weder nach `main` gemergt noch ein Coolify-Deployment gestartet.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Vendoo 1.43.0 – Updater Hotfix H2
|
||||
|
||||
## Behobener Fehler
|
||||
|
||||
Nach einer erfolgreichen Erstinstallation der GitHub CLI brach der Ein-Klick-Updater mit der Aufforderung ab, `UPDATE_VENDOO.bat` erneut zu starten. Ursache war, dass die laufende Windows-PowerShell-Sitzung den neu installierten Pfad noch nicht zuverlässig aufgelöst hatte.
|
||||
|
||||
## Neue Funktionsweise
|
||||
|
||||
- Der Updater aktualisiert den Prozess-PATH nach der Winget-Installation.
|
||||
- Er wartet kontrolliert bis zu 30 Sekunden auf die Registrierung der Programmdatei.
|
||||
- Zusätzlich werden die offiziellen Installationspfade der GitHub CLI direkt geprüft.
|
||||
- Gefundene Programme werden über ihren absoluten Pfad ausgeführt.
|
||||
- Nach der Installation läuft derselbe Updatevorgang ohne zweiten Doppelklick weiter.
|
||||
- Ein echter Abbruch erfolgt nur, wenn die installierte Programmdatei auch über die bekannten Pfade nicht auffindbar ist.
|
||||
|
||||
Produktcode und Datenbankschema bleiben gegenüber Vendoo 1.43.0 unverändert.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Vendoo 1.44.0 – UX Slice 16: Command Palette Registry Migration
|
||||
|
||||
## Referenz
|
||||
|
||||
- Branch: `release/1.44.0-ux-foundation-production-update-v1`
|
||||
- Ausgangs-Head: `eabbafbebd9288a258971c0a0a8abbeb27c9ea20`
|
||||
- App-Ziel: Vendoo `1.44.0`
|
||||
- Updater: `3.4.0`
|
||||
|
||||
## Problem
|
||||
|
||||
Die globale Ctrl+K-Palette führte vor diesem Slice mit `VIEW_LABELS` und `collectCommands()` eine zweite statische Command-Quelle. Dadurch konnten Registry-Metadaten, Modulregistrierungen, Aliase, Berechtigungen, deaktivierte Zustände, Favoriten und zuletzt geöffnete Bereiche nicht vollständig und konsistent ausgewertet werden.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `window.VendooCommands` bleibt alleinige Datenquelle der Palette.
|
||||
- Der bestehende Command-Vertrag wurde ausschließlich additiv um `aliases`, `visible`, `enabled` und `disabledReason` ergänzt.
|
||||
- `list(...)` löst Sichtbarkeit, Permission und Enablement vor der Darstellung auf.
|
||||
- Suchranking gewichtet Titel, Aliase, Keywords, Beschreibung und Gruppe; Favoriten und Recents liefern kontrollierte Ranking-Boosts.
|
||||
- `execute(...)` prüft den Command-Zustand unmittelbar vor der Ausführung erneut und emittiert bei Ablehnung `vendoo:command-blocked`.
|
||||
- Die Palette ruft ausschließlich `VendooCommands.execute(...)` auf. Sie führt keine direkte View-Navigation und keine fachliche Aktion aus.
|
||||
- Browser-History, Deep Links und Back/Forward bleiben unverändert im bestehenden globalen Navigationslayer.
|
||||
|
||||
## Accessibility und UX
|
||||
|
||||
- vollständige Tastaturnavigation mit Pfeiltasten, Home, End, Enter und Escape
|
||||
- Combobox-/Listbox-Vertrag mit `aria-activedescendant`
|
||||
- verständliche Anzeige deaktivierter Commands einschließlich Begründung
|
||||
- Fokusfalle im Dialog und verlässliche Rückgabe an den Auslöser
|
||||
- Live-Status für Trefferzahl, Auswahl und blockierte Commands
|
||||
- responsive Darstellung bis 320 px, Reduced Motion und 200-%-Zoom-taugliche Struktur
|
||||
- Ctrl+K überschreibt keine Eingabe- oder Contenteditable-Kontexte, solange die Palette geschlossen ist
|
||||
|
||||
## Daten-, Security- und Betriebsgrenzen
|
||||
|
||||
- keine Datenbankänderung
|
||||
- keine Migration
|
||||
- keine Route, API oder Modul-ID umbenannt
|
||||
- keine Netzwerkaufrufe aus Command Platform oder Palette
|
||||
- keine automatische Speicherung, Veröffentlichung, Installation, Deaktivierung oder Löschung
|
||||
- explizite Permission ohne auflösbaren Permission-Resolver wird in der UI nach dem Deny-by-default-Prinzip nicht dargestellt
|
||||
- serverseitige Berechtigungsprüfung bleibt maßgeblich
|
||||
|
||||
## Verifikation
|
||||
|
||||
Neues Gate: `npm run verify:command-palette-registry`
|
||||
|
||||
Das Gate prüft:
|
||||
|
||||
- Registry als alleinige Quelle
|
||||
- Wegfall von `VIEW_LABELS` und `collectCommands()`
|
||||
- Zustandsauflösung, Aliase und Ranking
|
||||
- Favoriten- und Recents-Integration
|
||||
- sichere Registry-Ausführung
|
||||
- Tastatur-, Fokus- und ARIA-Verträge
|
||||
- Loader-Reihenfolge
|
||||
- Ausschluss von Netzwerk-, Schreib-, Polling- und direkten Produktivaktionen
|
||||
- Einbindung in `verify:all`
|
||||
|
||||
## Rollback
|
||||
|
||||
Rollback erfolgt durch Rücknahme der Änderungen an:
|
||||
|
||||
- `public/command-platform.js`
|
||||
- `public/global-navigation-ux.js`
|
||||
- `public/global-navigation-ux.css`
|
||||
- `tools/verify-command-palette-registry-1.44.0.mjs`
|
||||
- `package.json`
|
||||
|
||||
Persistente Fach- oder Benutzerdaten werden durch diesen Slice nicht verändert. Die bestehenden Local-Storage-Keys für Favoriten und Recents bleiben unverändert.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Vendoo 1.44.0 – UX Slice 17: Universal Action Registry
|
||||
|
||||
## Referenz
|
||||
|
||||
- Branch: `release/1.44.0-ux-foundation-production-update-v1`
|
||||
- Ausgangs-Head: `7bc8a81bba5a220f4dda5bcc6911d40060c257a5`
|
||||
- App-Ziel: Vendoo `1.44.0`
|
||||
- Updater: `3.4.0`
|
||||
|
||||
## Ziel
|
||||
|
||||
Vendoo erhält eine zentrale, erweiterbare Action-Plattform. Fachmodule und UX-Workspaces registrieren Aktionen künftig einmalig. Aus demselben Vertrag können Command Palette, Toolbars, Kontextmenüs, Mobile Action Sheets, Shortcuts, Mehrfachauswahl und Plugin-Surfaces gespeist werden, ohne Handler oder Berechtigungslogik zu duplizieren.
|
||||
|
||||
## Öffentliche API
|
||||
|
||||
`window.VendooActions`
|
||||
|
||||
- `register(action, options)`
|
||||
- `registerMany(actions, options)`
|
||||
- `unregister(id)`
|
||||
- `get(id)`
|
||||
- `list(options)`
|
||||
- `resolve(id, context)`
|
||||
- `canExecute(id, context)`
|
||||
- `execute(id, context)`
|
||||
- `refresh(id?)`
|
||||
- `renderSurface(container, options)`
|
||||
- `mountSurface(container, options)`
|
||||
- `normalizeShortcut(shortcut)`
|
||||
- `setConfirmationProvider(provider)`
|
||||
- `setAuditSink(sink)`
|
||||
- `subscribe(listener)`
|
||||
|
||||
## Action-Vertrag
|
||||
|
||||
Verbindliche Felder:
|
||||
|
||||
- `id`
|
||||
- `title`
|
||||
- `handler`
|
||||
|
||||
Unterstützte Metadaten:
|
||||
|
||||
- `description`, `icon`, `group`
|
||||
- `aliases`, `keywords`
|
||||
- `surfaces`, `shortcut`, `priority`
|
||||
- `permission`, `visible`, `enabled`, `disabledReason`
|
||||
- `contexts`, `selection.min`, `selection.max`
|
||||
- `danger`: `none`, `caution`, `destructive`
|
||||
- `confirm`
|
||||
- `concurrency`: `single` oder `parallel`
|
||||
- `allowInInput`
|
||||
- `rollback`
|
||||
- `commandId`
|
||||
|
||||
Doppelte Action-IDs werden standardmäßig abgewiesen. Eine bewusste Ersetzung ist nur mit `{ replace: true }` möglich.
|
||||
|
||||
## Sicherheitsmodell
|
||||
|
||||
- Permissions werden vor Darstellung und unmittelbar vor Ausführung geprüft.
|
||||
- Eine deklarierte Permission ohne verfügbaren Resolver wird nach Deny-by-default nicht freigegeben.
|
||||
- Destruktive Actions benötigen immer eine zusätzliche Bestätigung.
|
||||
- `window.confirm` wird nicht verwendet; die Standardbestätigung ist ein barrierearmer Vendoo-Dialog.
|
||||
- Optional kann eine Bestätigungsphrase verlangt werden.
|
||||
- Single-Flight verhindert unbeabsichtigte Doppelausführungen.
|
||||
- Ein optionaler Rollback-Hook wird bei fehlgeschlagenen Actions kontrolliert ausgeführt.
|
||||
- Registry und UI-Surfaces implementieren keine Fachlogik und keine direkten Netzwerkaufrufe.
|
||||
- Serverseitige Berechtigungs-, CSRF- und Validierungsregeln bleiben maßgeblich.
|
||||
|
||||
## Plattformintegration
|
||||
|
||||
Actions mit der Surface `command-palette` werden automatisch als `kind: action` in `window.VendooCommands` registriert. Die Ctrl+K-Palette bleibt dadurch weiterhin vollständig Registry-getrieben.
|
||||
|
||||
Statusänderungen wie Running-, Selection-, Permission- oder Modulzustände aktualisieren den bestehenden Command-Adapter ohne vorherige Abmeldung. Dadurch bleiben favorisierte Action-Commands bei einem normalen Registry-Refresh erhalten. Eine Abmeldung erfolgt ausschließlich bei echter Action-Entfernung oder einer bewussten Ersetzung.
|
||||
|
||||
Deklarative UI-Elemente können mit `data-vendoo-action="<action-id>"` gebunden werden. Die Registry synchronisiert `disabled`, `aria-disabled`, Running- und Danger-Zustände. Dynamische Surfaces können über `renderSurface(...)` oder `mountSurface(...)` aufgebaut werden.
|
||||
|
||||
## Events und Audit-Anbindung
|
||||
|
||||
Die Registry emittiert unter anderem:
|
||||
|
||||
- `vendoo:actions-ready`
|
||||
- `vendoo:actions-changed`
|
||||
- `vendoo:action-state-changed`
|
||||
- `vendoo:action-executing`
|
||||
- `vendoo:action-executed`
|
||||
- `vendoo:action-failed`
|
||||
- `vendoo:action-blocked`
|
||||
- `vendoo:action-cancelled`
|
||||
- `vendoo:action-rolled-back`
|
||||
- `vendoo:action-rollback-failed`
|
||||
- `vendoo:action-shortcut-conflict`
|
||||
|
||||
Ein optionaler Audit Sink kann diese bereits bereinigten Lifecycle-Ereignisse übernehmen. Die Registry selbst sendet keine Telemetrie und speichert keine fachlichen Nutzdaten.
|
||||
|
||||
## Accessibility und UX
|
||||
|
||||
- semantische Buttons und ARIA-Zustände
|
||||
- vollständige Tastaturbedienung
|
||||
- Shortcuts mit Konflikterkennung und Prioritäten
|
||||
- keine Shortcut-Ausführung in Eingabefeldern ohne explizites `allowInInput`
|
||||
- Fokusfalle und Fokus-Rückgabe im Bestätigungsdialog
|
||||
- responsive Toolbar-, Menü- und Sheet-Surfaces
|
||||
- Reduced-Motion-Unterstützung
|
||||
- Theme-Token statt fest verdrahteter Produktfarben
|
||||
|
||||
## Verifikation
|
||||
|
||||
Neues Gate: `npm run verify:universal-actions`
|
||||
|
||||
Das Gate prüft statisch und in einer isolierten Runtime:
|
||||
|
||||
- öffentlichen Registry-Vertrag
|
||||
- Command-Palette-Adapter
|
||||
- Erhalt registrierter Command-Adapter und Favoriten bei Status-Refreshes
|
||||
- Kontext-, Permission- und Auswahlauflösung
|
||||
- Duplicate-ID-Schutz
|
||||
- destruktive Bestätigung
|
||||
- Single-Flight
|
||||
- Rollback
|
||||
- Lifecycle-Events
|
||||
- Shortcut-Normalisierung
|
||||
- DOM-/Surface-Vertrag
|
||||
- Asset-Ladereihenfolge
|
||||
- Ausschluss von Netzwerk-, Polling-, innerHTML- und direkten Fachaktionen
|
||||
- Einbindung in `verify:all`
|
||||
|
||||
## Daten-, Release- und Rollback-Grenzen
|
||||
|
||||
- keine Datenbankänderung
|
||||
- keine Migration
|
||||
- keine Route, API oder Modul-ID umbenannt
|
||||
- keine bestehende Fachaktion automatisch migriert oder ausgelöst
|
||||
- kein Merge, Deployment oder Release
|
||||
|
||||
Rollback erfolgt durch Rücknahme von:
|
||||
|
||||
- `public/action-registry.js`
|
||||
- `public/action-registry.css`
|
||||
- Loader-Erweiterung in `public/module-extension-workspace.js`
|
||||
- `tools/verify-universal-action-registry-1.44.0.mjs`
|
||||
- `package.json`
|
||||
|
||||
Persistente Artikel-, Medien-, Benutzer-, Modul-, Publishing- oder Einstellungsdaten werden nicht verändert.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Vendoo 1.44.0 – UX Slice 18: Safe Action Adapters
|
||||
|
||||
## Referenz
|
||||
|
||||
- Branch: `release/1.44.0-ux-foundation-production-update-v1`
|
||||
- Ausgangs-Head: `bd87f739992085bf0d06060b9b075bb5d2219175`
|
||||
- App-Ziel: Vendoo `1.44.0`
|
||||
- Updater: `3.4.0`
|
||||
|
||||
## Ziel
|
||||
|
||||
Die Universal Action Registry aus Slice 17 wird erstmals mit bestehenden Vendoo-Funktionen verbunden. Der Slice migriert ausschließlich nicht destruktive Aktionen. Jeder Adapter delegiert an einen bereits vorhandenen, geprüften DOM-Handler und enthält keine neue Fach-, Netzwerk- oder Persistenzlogik.
|
||||
|
||||
## Migrierte Aktionen
|
||||
|
||||
### Module & Extensions
|
||||
|
||||
- `workspace:modules:refresh`
|
||||
- ruft ausschließlich den vorhandenen Button `#module-manager-refresh` auf
|
||||
- `workspace:modules:filter-all`
|
||||
- delegiert an den vorhandenen Modulfilter `all`
|
||||
- `workspace:modules:filter-problems`
|
||||
- delegiert an den vorhandenen Modulfilter `problems`
|
||||
|
||||
### Artikel
|
||||
|
||||
- `workspace:articles:open-current`
|
||||
- öffnet den zuletzt fokussierten oder ausgewählten Artikel über `.history-article-title`
|
||||
- `workspace:articles:view-cards`
|
||||
- delegiert an die bestehende Kartenansicht
|
||||
- `workspace:articles:view-list`
|
||||
- delegiert an die bestehende Listenansicht
|
||||
|
||||
### Medien
|
||||
|
||||
- `workspace:media:open-current-preview`
|
||||
- öffnet das zuletzt fokussierte oder ausgewählte Bild über den vorhandenen Preview-Handler
|
||||
- `workspace:media:density-comfortable`
|
||||
- delegiert an die vorhandene mittlere Galeriedichte
|
||||
- `workspace:media:reset-filters`
|
||||
- delegiert an den vorhandenen Medienfilter-Reset
|
||||
|
||||
## Kontextmodell
|
||||
|
||||
Artikel- und Medienaktionen arbeiten nicht mit willkürlich gewählten Datensätzen. Der Adapter merkt sich ausschließlich den zuletzt per Fokus oder Pointer adressierten Artikel beziehungsweise die zuletzt adressierte Medienvorschau.
|
||||
|
||||
Falls kein solcher Kontext vorhanden ist, kann alternativ eine bereits ausgewählte Zeile beziehungsweise Medienkarte verwendet werden. Ohne eindeutigen Kontext bleibt die Action deaktiviert.
|
||||
|
||||
Zusätzlich wird jede Action gegen `window.VendooNavigation.currentView` geprüft. Modulaktionen sind nur in `admin`, Artikelaktionen nur in `history` und Medienaktionen nur in `media-library` sichtbar. Ein technisch noch vorhandener, aber inaktiver DOM-Bereich reicht ausdrücklich nicht aus.
|
||||
|
||||
## Command-Palette-Integration
|
||||
|
||||
Alle neun Adapter werden über `window.VendooActions.registerMany(...)` registriert und nutzen ausschließlich die Surface `command-palette`.
|
||||
|
||||
Dadurch erscheinen sie in der bestehenden Ctrl+K-Palette, ohne eine zweite Command-Liste einzuführen. Sichtbarkeit und Enablement werden unmittelbar vor Darstellung und Ausführung erneut gegen den aktuellen DOM- und Workspace-Kontext geprüft.
|
||||
|
||||
## Sicherheitsgrenzen
|
||||
|
||||
- ausschließlich `danger: none`
|
||||
- keine destruktiven oder vorsichtigen Actions
|
||||
- keine Veröffentlichung
|
||||
- keine Löschung
|
||||
- keine Installation oder Deinstallation
|
||||
- keine Modulaktivierung oder -deaktivierung
|
||||
- keine Statusänderung
|
||||
- keine Speicherung
|
||||
- keine Datenbankänderung
|
||||
- keine Migration
|
||||
- keine neuen API-Aufrufe
|
||||
- kein Polling
|
||||
- kein `window.confirm`
|
||||
- kein `innerHTML`
|
||||
|
||||
Die Adapter rufen nur vorhandene Buttons oder Links per `click()` auf. Serverseitige Berechtigungen, CSRF-Schutz, Validierungen und bestehende Handler bleiben unverändert maßgeblich.
|
||||
|
||||
## Asset-Ladereihenfolge
|
||||
|
||||
`public/module-extension-workspace.js` lädt jetzt in dieser Reihenfolge:
|
||||
|
||||
1. Command Platform
|
||||
2. Universal Action Registry
|
||||
3. Global Navigation
|
||||
4. Safe Action Adapters
|
||||
|
||||
Der Adapter wartet zusätzlich auf `vendoo:actions-ready`, falls die Action Registry beim Laden noch nicht verfügbar ist.
|
||||
|
||||
## Verifikation
|
||||
|
||||
Neues Gate:
|
||||
|
||||
```bash
|
||||
npm run verify:safe-action-adapters
|
||||
```
|
||||
|
||||
Das Gate prüft statisch und in einer isolierten Runtime:
|
||||
|
||||
- alle neun stabilen Action-IDs
|
||||
- ausschließliche Command-Palette-Surface
|
||||
- ausschließlich Gefahrstufe `none`
|
||||
- aktive Workspace-Grenzen über `VendooNavigation.currentView`
|
||||
- korrekte Asset-Ladereihenfolge
|
||||
- Modul-Refresh-Delegation
|
||||
- Modulfilter-Delegation
|
||||
- Artikelansicht-Delegation
|
||||
- fokussierten Artikelkontext
|
||||
- fokussierten Medienkontext
|
||||
- Galeriedichte und Medienfilter-Reset
|
||||
- Ready-Event
|
||||
- Ausschluss von Netzwerk-, Polling-, `innerHTML`-, Bestätigungs- und direkter Fachlogik
|
||||
- Einbindung in `verify:all`
|
||||
|
||||
Lokal bestanden:
|
||||
|
||||
```text
|
||||
node --check public/safe-action-adapters.js
|
||||
node --check tools/verify-safe-action-adapters-1.44.0.mjs
|
||||
Safe Action Adapter Gate bestanden.
|
||||
```
|
||||
|
||||
## Daten- und Releasegrenzen
|
||||
|
||||
- keine bestehenden Routen, APIs, Feld-IDs oder Modul-IDs umbenannt
|
||||
- keine bestehenden Handler ersetzt
|
||||
- keine persistenten Nutzdaten verändert
|
||||
- kein Merge
|
||||
- kein Deployment
|
||||
- kein Release
|
||||
@@ -0,0 +1,97 @@
|
||||
# Vendoo 1.44.0 – Slice 19 CI-Diagnosevertrag
|
||||
|
||||
## Zweck
|
||||
|
||||
Der vollständige Vendoo-Releasevertrag prüft das Release-Manifest bewusst sehr früh. Solange das Manifest nicht auf den aktuellen Branch-Head synchronisiert ist, können nachgelagerte UX-Gates in diesem Workflow nicht eindeutig ausgewertet werden.
|
||||
|
||||
Slice 19 führt deshalb einen unabhängigen, minimalen Plattformvertrag ein. Dieser ersetzt den vollständigen Releasevertrag nicht, sondern trennt den fachlichen Zustand von Navigation API und Safe Action Adapters vom Paket- und Manifestzustand.
|
||||
|
||||
## Workflow
|
||||
|
||||
Datei:
|
||||
|
||||
```text
|
||||
.github/workflows/ux-platform-contract.yml
|
||||
```
|
||||
|
||||
Matrix:
|
||||
|
||||
- `ubuntu-latest`
|
||||
- `windows-latest`
|
||||
|
||||
## Unabhängigkeit
|
||||
|
||||
Der Workflow verwendet bewusst:
|
||||
|
||||
- keine `actions/checkout`-Action
|
||||
- keine `actions/setup-node`-Action
|
||||
- kein `npm ci`
|
||||
- keinen Dependency-Cache
|
||||
- keine Drittanbieter-Action
|
||||
|
||||
Der exakte Workflow-Commit wird ausschließlich mit dem auf dem GitHub-Runner vorhandenen `git` ausgecheckt:
|
||||
|
||||
```text
|
||||
git init .
|
||||
git remote add origin <Repository-URL>
|
||||
git fetch --depth=1 origin <GITHUB_SHA>
|
||||
git checkout --detach FETCH_HEAD
|
||||
```
|
||||
|
||||
Damit hängen die beiden UX-Gates weder von NPM-Abhängigkeiten noch von Marketplace-Actions ab.
|
||||
|
||||
## Ausgeführte Gates
|
||||
|
||||
Der Runner `tools/run-ux-platform-gates.mjs` startet mit dem vorhandenen Node-Prozess:
|
||||
|
||||
1. `tools/verify-navigation-api-1.44.0.mjs`
|
||||
2. `tools/verify-safe-action-adapters-1.44.0.mjs`
|
||||
|
||||
Beide Verifier verwenden ausschließlich Node-Coremodule.
|
||||
|
||||
## Diagnoseausgabe
|
||||
|
||||
Der Runner protokolliert:
|
||||
|
||||
- Betriebssystem
|
||||
- Node-Version
|
||||
- Architektur
|
||||
- geprüften Commit
|
||||
- Standardausgabe jedes Gates
|
||||
- Fehlerausgabe jedes Gates
|
||||
- Exitcode jedes Gates
|
||||
- Gesamtergebnis
|
||||
|
||||
Die Ausgabe wird zusätzlich unter folgendem Pfad erzeugt:
|
||||
|
||||
```text
|
||||
artifacts/ux-platform-gates.log
|
||||
```
|
||||
|
||||
Der abschließende Workflow-Step gibt vorhandene Diagnoseprotokolle unabhängig vom Gate-Ergebnis erneut in das Jobprotokoll aus.
|
||||
|
||||
## Abgrenzung
|
||||
|
||||
Ein grüner UX-Plattformvertrag bestätigt ausschließlich:
|
||||
|
||||
- öffentliche Navigation API
|
||||
- Navigation-Lifecycle
|
||||
- Workspace-Grenzen
|
||||
- 21 Safe Action Adapters
|
||||
- bestehende Handlerdelegation
|
||||
- Linux-/Windows-Kompatibilität dieser Verifier
|
||||
|
||||
Er bestätigt nicht automatisch:
|
||||
|
||||
- synchrones Release-Manifest
|
||||
- vollständigen Windows-Releasevertrag
|
||||
- Docker-Build
|
||||
- Updater-Paket
|
||||
- Deployment- oder Releasefreigabe
|
||||
|
||||
## Releasegrenzen
|
||||
|
||||
- kein Merge
|
||||
- kein Deployment
|
||||
- kein Release
|
||||
- keine Abschwächung des vollständigen Releasevertrags
|
||||
@@ -0,0 +1,172 @@
|
||||
# Vendoo 1.44.0 – UX Slice 19: Safe Action Expansion
|
||||
|
||||
## Referenz
|
||||
|
||||
- Branch: `release/1.44.0-ux-foundation-production-update-v1`
|
||||
- Ausgangs-Head: `dd9596ef857b20c63a8773837a8853d7a657e4a9`
|
||||
- App-Ziel: Vendoo `1.44.0`
|
||||
- Updater: `3.4.0`
|
||||
|
||||
## Ziel
|
||||
|
||||
Slice 19 vervollständigt die erste sichere Action-Migrationsstufe. Die neun Adapter aus Slice 18 bleiben unverändert verfügbar und werden um zwölf ausschließlich darstellende oder filternde Actions ergänzt.
|
||||
|
||||
Es entstehen keine neuen Fachaktionen. Jeder neue Eintrag delegiert weiterhin ausschließlich an einen bereits vorhandenen Vendoo-Button.
|
||||
|
||||
## Vollständiger Action-Umfang
|
||||
|
||||
Die Safe Action Adapter registrieren jetzt exakt 21 Actions.
|
||||
|
||||
### Module
|
||||
|
||||
- Module neu laden
|
||||
- alle Module anzeigen
|
||||
- aktive Module anzeigen
|
||||
- deaktivierte Module anzeigen
|
||||
- installierte externe Module anzeigen
|
||||
- Module mit Problemen anzeigen
|
||||
|
||||
### Browser-Extensions
|
||||
|
||||
- alle Extensions anzeigen
|
||||
- bereite Extensions anzeigen
|
||||
- verbundene Extensions anzeigen
|
||||
- Extensions mit Handlungsbedarf anzeigen
|
||||
|
||||
### Artikel
|
||||
|
||||
- aktuellen Artikel öffnen
|
||||
- Kartenansicht aktivieren
|
||||
- Listenansicht aktivieren
|
||||
- Artikelfilter zurücksetzen
|
||||
|
||||
### Medien
|
||||
|
||||
- aktuelles Bild groß öffnen
|
||||
- kompakte Galeriedichte aktivieren
|
||||
- normale Galeriedichte aktivieren
|
||||
- große Galeriedichte aktivieren
|
||||
- Rasteransicht aktivieren
|
||||
- Listenansicht aktivieren
|
||||
- Medienfilter zurücksetzen
|
||||
|
||||
## Deklarativer Adapterkern
|
||||
|
||||
Wiederkehrende Button-Delegationen werden jetzt über `buttonAction(...)` beschrieben. Der Helper erzeugt ausschließlich:
|
||||
|
||||
- `enabled`, indem der vorhandene Zielbutton geprüft wird
|
||||
- `handler`, indem exakt dieser Zielbutton per `click()` ausgelöst wird
|
||||
|
||||
Dadurch bleiben Action-Metadaten und technische Delegation zentral, einheitlich und leichter prüfbar. Artikel- und Medienkontextaktionen behalten ihre spezialisierten Handler.
|
||||
|
||||
## Öffentliche Navigation API
|
||||
|
||||
Bei der Härtung wurde festgestellt, dass die Safe Action Adapter bereits optional `window.VendooNavigation.currentView` berücksichtigten, die globale Navigation dieses API aber noch nicht veröffentlichte.
|
||||
|
||||
`public/navigation-api.js` schließt diese Vertragslücke und stellt jetzt ein schreibgeschütztes API bereit:
|
||||
|
||||
- `window.VendooNavigation.currentView`
|
||||
- dynamischer Getter für die tatsächlich aktive Vendoo-Ansicht
|
||||
- `window.VendooNavigation.canOpen(view)`
|
||||
- prüft vorhandenen, sichtbaren und aktivierbaren Navigationseintrag
|
||||
- `window.VendooNavigation.open(view)`
|
||||
- delegiert ausschließlich per `click()` an den vorhandenen Navigationseintrag
|
||||
|
||||
Das API führt keine eigene History-, Routing- oder URL-Logik ein. Die bestehende globale Navigation bleibt alleinige Quelle dieser Abläufe.
|
||||
|
||||
Zusätzlich werden folgende Lifecycle-Events veröffentlicht:
|
||||
|
||||
- `vendoo:navigation-api-ready`
|
||||
- `vendoo:navigation-changed`
|
||||
|
||||
Der Navigation-Observer reagiert ausschließlich auf Änderungen von `class`, `hidden`, `aria-hidden` und `aria-disabled` und wird bei `beforeunload` sauber getrennt.
|
||||
|
||||
## Workspace-Grenzen
|
||||
|
||||
Sichtbarkeit wird gegen `window.VendooNavigation.currentView` und den zugehörigen DOM-Bereich geprüft:
|
||||
|
||||
- Modulaktionen: `admin`
|
||||
- Extension-Aktionen: `extensions`
|
||||
- Artikelaktionen: `history`
|
||||
- Medienaktionen: `media-library`
|
||||
|
||||
Actions eines inaktiven Arbeitsbereichs erscheinen nicht in der Command Palette.
|
||||
|
||||
## Asset-Ladereihenfolge
|
||||
|
||||
Die Plattformassets werden jetzt in folgender Reihenfolge geladen:
|
||||
|
||||
1. Command Platform
|
||||
2. Universal Action Registry
|
||||
3. Global Navigation
|
||||
4. Navigation API
|
||||
5. Safe Action Adapters
|
||||
|
||||
Damit ist der öffentliche Navigationsvertrag vor der ersten Action-Auswertung verfügbar.
|
||||
|
||||
## Sicherheitsgrenzen
|
||||
|
||||
Alle 21 Actions erfüllen denselben Vertrag:
|
||||
|
||||
- ausschließlich `danger: none`
|
||||
- ausschließlich Surface `command-palette`
|
||||
- keine Veröffentlichung
|
||||
- keine Löschung
|
||||
- keine Installation oder Deinstallation
|
||||
- keine Aktivierung oder Deaktivierung eines Moduls
|
||||
- keine Status- oder Datenänderung
|
||||
- keine Speicherung
|
||||
- keine Datenbankänderung
|
||||
- keine Migration
|
||||
- kein eigener API-Aufruf
|
||||
- kein Polling
|
||||
- kein `window.confirm`
|
||||
- kein `innerHTML`
|
||||
|
||||
Auch die Navigation API führt keine Netzwerkaufrufe, externe Navigation oder eigene Browser-History-Manipulation aus.
|
||||
|
||||
## Abnahme-Gates
|
||||
|
||||
Das vorhandene Gate `npm run verify:safe-action-adapters` wurde auf den vollständigen 21-Action-Vertrag erweitert.
|
||||
|
||||
Es prüft jetzt zusätzlich:
|
||||
|
||||
- exakt 21 eindeutige Action-IDs
|
||||
- zwölf neue Expansion-Actions
|
||||
- alle vier Workspace-Grenzen
|
||||
- Modulfilter für aktiv, deaktiviert und installiert
|
||||
- Extension-Filter für bereit, verbunden und Handlungsbedarf
|
||||
- Artikelfilter-Reset
|
||||
- kompakte und große Galeriedichte
|
||||
- Medien-Raster- und Listenansicht
|
||||
- Ready-Event mit
|
||||
- `count: 21`
|
||||
- `baselineCount: 9`
|
||||
- `expansionCount: 12`
|
||||
|
||||
Neues Navigation-API-Gate:
|
||||
|
||||
```bash
|
||||
npm run verify:navigation-api
|
||||
```
|
||||
|
||||
Es prüft:
|
||||
|
||||
- schreibgeschütztes öffentliches API
|
||||
- dynamischen `currentView`-Getter
|
||||
- `canOpen` für sichtbare, versteckte und unbekannte Ansichten
|
||||
- kontrollierte Delegation von `open()`
|
||||
- vollständige Lifecycle-Events
|
||||
- Observer-Aufbau und Cleanup
|
||||
- Asset-Ladereihenfolge
|
||||
- Ausschluss eigener History-, Netzwerk-, Polling- und externer Navigationslogik
|
||||
- Einbindung in `verify:all`
|
||||
|
||||
## Releasegrenzen
|
||||
|
||||
- keine bestehenden Handler ersetzt
|
||||
- keine bestehenden Routen, APIs, Feld-IDs oder Modul-IDs geändert
|
||||
- keine persistenten Nutzdaten verändert
|
||||
- kein Merge
|
||||
- kein Deployment
|
||||
- kein Release
|
||||
+81
@@ -1550,3 +1550,84 @@ try {
|
||||
db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description)
|
||||
VALUES ('operations-center-1.30.0', '1.30.0', 'Backup, Restore, Update Center und Extension-Diagnose')`).run();
|
||||
} catch {}
|
||||
|
||||
|
||||
// --- Production Operations & Controlled Updates 1.42.0 ---
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS deployment_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
state TEXT NOT NULL DEFAULT 'checking',
|
||||
provider TEXT NOT NULL DEFAULT 'coolify',
|
||||
trigger_mode TEXT NOT NULL DEFAULT 'webhook',
|
||||
current_version TEXT NOT NULL,
|
||||
target_version TEXT NOT NULL,
|
||||
build_revision_before TEXT,
|
||||
build_revision_after TEXT,
|
||||
deployment_id TEXT,
|
||||
backup_id TEXT,
|
||||
requested_by INTEGER,
|
||||
reason TEXT,
|
||||
github_repository TEXT,
|
||||
github_branch TEXT,
|
||||
request_json TEXT NOT NULL DEFAULT '{}',
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
timeout_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY(requested_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY(backup_id) REFERENCES operation_backups(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_deployment_runs_state ON deployment_runs(state, updated_at DESC)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_deployment_runs_created ON deployment_runs(created_at DESC)`);
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS deployment_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY(run_id) REFERENCES deployment_runs(id) ON DELETE CASCADE
|
||||
)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_deployment_events_run ON deployment_events(run_id, id ASC)`);
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS backup_verifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
backup_id TEXT NOT NULL,
|
||||
deployment_run_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
sha256 TEXT,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
sqlite_integrity TEXT,
|
||||
manifest_json TEXT NOT NULL DEFAULT '{}',
|
||||
errors_json TEXT NOT NULL DEFAULT '[]',
|
||||
warnings_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
verified_at TEXT,
|
||||
FOREIGN KEY(backup_id) REFERENCES operation_backups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(deployment_run_id) REFERENCES deployment_runs(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_backup_verifications_backup ON backup_verifications(backup_id, created_at DESC)`);
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS production_checks (
|
||||
id TEXT PRIMARY KEY,
|
||||
overall_status TEXT NOT NULL,
|
||||
passed_count INTEGER NOT NULL DEFAULT 0,
|
||||
warning_count INTEGER NOT NULL DEFAULT 0,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
results_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_by INTEGER,
|
||||
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_production_checks_created ON production_checks(created_at DESC)`);
|
||||
|
||||
db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description)
|
||||
VALUES ('production-operations-1.42.0', '1.42.0', 'Persistente Deployment-State-Machine, Deployment-Events, Backup-Verifikation und Produktionschecks')`).run();
|
||||
} catch {}
|
||||
|
||||
@@ -330,20 +330,33 @@ export function cancelPendingOperation() {
|
||||
return { cancelled: true, pending };
|
||||
}
|
||||
|
||||
export async function checkForUpdates() {
|
||||
export async function checkForUpdates({ channel = 'stable' } = {}) {
|
||||
const settings = getOperationSettings();
|
||||
const url = String(settings.update_manifest_url || '').trim();
|
||||
if (!url) return { configured: false, current_version: APP_VERSION, message: 'Keine Update-Manifest-URL konfiguriert.' };
|
||||
if (!/^https?:\/\//i.test(url)) throw new Error('Update-Manifest muss über HTTP oder HTTPS erreichbar sein.');
|
||||
const rawUrl = String(settings.update_manifest_url || '').trim();
|
||||
if (!rawUrl) return { configured: false, current_version: APP_VERSION, channel, message: 'Keine Update-Manifest-URL konfiguriert.' };
|
||||
let url;
|
||||
try { url = new URL(rawUrl); } catch { throw new Error('Update-Manifest-URL ist ungültig.'); }
|
||||
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('Update-Manifest muss über HTTP oder HTTPS erreichbar sein.');
|
||||
if (url.username || url.password || url.hash) throw new Error('Update-Manifest-URL darf keine Zugangsdaten oder Fragmente enthalten.');
|
||||
if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:') throw new Error('Update-Manifest muss in Produktion HTTPS verwenden.');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'error',
|
||||
headers: { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Update-Manifest antwortet mit HTTP ${response.status}.`);
|
||||
const manifest = await response.json();
|
||||
const contentLength = Number(response.headers.get('content-length') || 0);
|
||||
if (contentLength > 256 * 1024) throw new Error('Update-Manifest ist größer als 256 KB.');
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text, 'utf8') > 256 * 1024) throw new Error('Update-Manifest ist größer als 256 KB.');
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(text); } catch { throw new Error('Update-Manifest enthält kein gültiges JSON.'); }
|
||||
const latest = String(manifest.version || manifest.latest_version || '');
|
||||
if (!latest) throw new Error('Update-Manifest enthält keine Version.');
|
||||
return { configured: true, current_version: APP_VERSION, latest_version: latest, update_available: compareVersions(latest, APP_VERSION) > 0, manifest };
|
||||
return { configured: true, current_version: APP_VERSION, latest_version: latest, update_available: compareVersions(latest, APP_VERSION) > 0, channel, manifest };
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "vendoo",
|
||||
"version": "1.41.2",
|
||||
"version": "1.43.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vendoo",
|
||||
"version": "1.41.2",
|
||||
"version": "1.43.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
|
||||
+42
-11
@@ -1,40 +1,71 @@
|
||||
{
|
||||
"name": "vendoo",
|
||||
"version": "1.41.2",
|
||||
"version": "1.43.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node bootstrap.mjs",
|
||||
"dev": "node --watch bootstrap.mjs",
|
||||
"check": "node tools/check-syntax.mjs && python -m py_compile tools/verify-identity-db-1.40.0.py",
|
||||
"check": "node tools/check-syntax.mjs && node --no-warnings tools/verify-identity-db-1.40.0.mjs",
|
||||
"verify:slice30": "node tools/verify-slice30-mock.mjs",
|
||||
"verify:db": "python tools/verify-db-migrations.py",
|
||||
"verify:db": "node --no-warnings tools/verify-db-migrations.mjs",
|
||||
"verify:slice31": "node tools/verify-slice31-image-editor.mjs",
|
||||
"verify:hotfix": "node tools/verify-release-1.25.1.mjs",
|
||||
"verify:gallery": "node tools/verify-release-1.25.1-static.mjs",
|
||||
"verify:release": "node tools/verify-deployment-1.41.2.mjs",
|
||||
"verify:release": "npm run verify:production-operations",
|
||||
"verify:ui-contracts": "node tools/verify-ui-contracts-1.26.1.mjs",
|
||||
"verify:deployment": "node tools/verify-deployment-1.41.2.mjs",
|
||||
"verify:deployment": "npm run verify:production-operations",
|
||||
"verify:git": "node tools/verify-git-safety.mjs",
|
||||
"verify:config": "node tools/verify-config-store.mjs",
|
||||
"verify:installer-shell": "sh tools/verify-installer-shell.sh",
|
||||
"verify:installer-shell": "node tools/verify-installer-shell.mjs",
|
||||
"verify:git-regression": "node tools/verify-git-safety-regression.mjs",
|
||||
"verify:git-staging": "node tools/verify-git-staging.mjs",
|
||||
"generate:tokens": "node tools/generate-design-tokens.mjs",
|
||||
"verify:architecture": "node tools/verify-platform-architecture-1.41.2.mjs",
|
||||
"verify:all": "npm run verify:git && npm run verify:git-regression && npm run verify:git-staging && npm run verify:ignore-boundaries && npm run check && npm run verify:source-boundaries && npm run verify:esm-exports && npm run verify:flux-loop && npm run verify:capabilities && npm run verify:coolify && npm run verify:initial-modules && npm run verify:architecture && npm run verify:themes && npm run verify:security && npm run verify:identity && npm run verify:catalog && npm run verify:modules && npm run verify:deployment && npm run verify:config && npm run verify:installer-shell && npm run verify:db",
|
||||
"verify:ux-foundation": "node tools/verify-ux-foundation-1.44.0.mjs",
|
||||
"verify:generator-workflow": "node tools/verify-generator-workflow-1.44.0.mjs",
|
||||
"verify:article-overview": "node tools/verify-article-overview-ux-1.44.0.mjs",
|
||||
"verify:publish-center": "node tools/verify-publish-center-ux-1.44.0.mjs",
|
||||
"verify:media-library": "node tools/verify-media-library-ux-1.44.0.mjs",
|
||||
"verify:image-factory": "node tools/verify-image-factory-ux-1.44.0.mjs",
|
||||
"verify:flux-studio-ux": "node tools/verify-flux-studio-ux-1.44.0.mjs",
|
||||
"verify:quality-center-ux": "node tools/verify-quality-center-ux-1.44.0.mjs",
|
||||
"verify:inventory-workspace": "node tools/verify-inventory-workspace-ux-1.44.0.mjs",
|
||||
"verify:template-workspace": "node tools/verify-template-workspace-ux-1.44.0.mjs",
|
||||
"verify:today-workspace": "node tools/verify-today-workspace-ux-1.44.0.mjs",
|
||||
"verify:settings-control-center": "node tools/verify-settings-control-center-ux-1.44.0.mjs",
|
||||
"verify:module-extension-workspace": "node tools/verify-module-extension-workspace-ux-1.44.0.mjs",
|
||||
"verify:global-navigation": "node tools/verify-global-navigation-ux-1.44.0.mjs",
|
||||
"verify:command-platform": "node tools/verify-command-platform-ux-1.44.0.mjs",
|
||||
"verify:command-palette-registry": "node tools/verify-command-palette-registry-1.44.0.mjs",
|
||||
"verify:universal-actions": "node tools/verify-universal-action-registry-1.44.0.mjs",
|
||||
"verify:navigation-api": "node tools/verify-navigation-api-1.44.0.mjs",
|
||||
"verify:safe-action-adapters": "node tools/verify-safe-action-adapters-1.44.0.mjs",
|
||||
"verify:all": "npm run verify:git && npm run verify:git-regression && npm run verify:git-staging && npm run verify:release-manifest && npm run verify:ignore-boundaries && npm run check && npm run verify:powershell-static && npm run verify:source-boundaries && npm run verify:esm-exports && npm run verify:flux-loop && npm run verify:capabilities && npm run verify:coolify && npm run verify:initial-modules && npm run verify:architecture && npm run verify:ux-foundation && npm run verify:generator-workflow && npm run verify:article-overview && npm run verify:publish-center && npm run verify:media-library && npm run verify:image-factory && npm run verify:flux-studio-ux && npm run verify:quality-center-ux && npm run verify:inventory-workspace && npm run verify:template-workspace && npm run verify:today-workspace && npm run verify:settings-control-center && npm run verify:module-extension-workspace && npm run verify:global-navigation && npm run verify:command-platform && npm run verify:command-palette-registry && npm run verify:universal-actions && npm run verify:navigation-api && npm run verify:safe-action-adapters && npm run verify:themes && npm run verify:security && npm run verify:identity && npm run verify:invitations && npm run verify:sqlite-cleanup && npm run verify:catalog && npm run verify:modules && npm run verify:production-operations && npm run verify:updater-start-chain && npm run verify:updater-ci-lifecycle && npm run verify:one-click-update && npm run verify:module-navigation && npm run verify:updater-ui && npm run verify:updater-hotfix && npm run verify:deployment && npm run verify:config && npm run verify:installer-shell && npm run verify:db",
|
||||
"verify:themes": "node tools/verify-theme-manager-1.41.2.mjs",
|
||||
"verify:security": "node tools/verify-security-foundation-1.41.2.mjs",
|
||||
"verify:identity": "node tools/verify-identity-settings-1.41.2.mjs && python tools/verify-identity-db-1.40.0.py",
|
||||
"verify:identity": "node tools/verify-identity-settings-1.41.2.mjs && node --no-warnings tools/verify-identity-db-1.40.0.mjs",
|
||||
"verify:catalog": "node tools/verify-catalog-media-1.41.2.mjs && node tools/verify-catalog-services-1.41.2.mjs",
|
||||
"verify:modules": "node tools/verify-module-manager-1.41.2.mjs",
|
||||
"verify:esm-exports": "node tools/verify-esm-export-contracts.mjs",
|
||||
"verify:flux-loop": "node tools/verify-flux-loop-hotfix-1.41.2.mjs",
|
||||
"verify:capabilities": "node tools/verify-capability-graph-1.41.2.mjs",
|
||||
"verify:coolify": "node tools/verify-coolify-deployment-1.41.2.mjs",
|
||||
"verify:initial-modules": "node tools/verify-initial-disabled-modules-1.41.2.mjs",
|
||||
"verify:coolify": "npm run verify:production-operations",
|
||||
"verify:initial-modules": "node tools/verify-initial-disabled-modules-1.41.2.mjs && node tools/verify-initial-module-cleanup-regression-1.43.0.mjs",
|
||||
"verify:source-boundaries": "node tools/verify-source-boundaries-1.41.2.mjs",
|
||||
"verify:ignore-boundaries": "node tools/verify-git-ignore-boundaries-1.41.2.mjs"
|
||||
"verify:ignore-boundaries": "node tools/verify-git-ignore-boundaries-1.41.2.mjs",
|
||||
"verify:production-operations": "node tools/verify-production-operations-1.42.0.mjs && node --no-warnings tools/verify-production-operations-db-1.42.0.mjs",
|
||||
"verify:invitations": "node tools/verify-controlled-invitations-1.42.0.mjs",
|
||||
"verify:one-click-update": "node tools/verify-production-updater-3.4.mjs",
|
||||
"verify:module-navigation": "node tools/verify-module-navigation-generator-1.43.0.mjs",
|
||||
"verify:updater-ui": "node tools/verify-production-updater-3.4.mjs",
|
||||
"verify:updater-hotfix": "node tools/verify-production-updater-3.4.mjs",
|
||||
"verify:updater-lifecycle": "node tools/verify-production-updater-3.4.mjs",
|
||||
"verify:updater-start-chain": "node tools/verify-updater-start-chain-3.4.mjs",
|
||||
"verify:updater-ci-lifecycle": "node tools/verify-updater-ci-lifecycle-3.4.mjs",
|
||||
"verify:powershell-static": "node tools/verify-powershell-static.mjs",
|
||||
"verify:sqlite-cleanup": "node tools/verify-sqlite-cleanup-contract-1.43.0.mjs && node tools/verify-windows-sqlite-cleanup-regression-1.43.0.mjs",
|
||||
"verify:release-manifest": "node tools/verify-release-manifest-staging-3.0.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,617 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
if (document.documentElement.dataset.uxActionRegistryReady === '1') return;
|
||||
document.documentElement.dataset.uxActionRegistryReady = '1';
|
||||
|
||||
const registry = new Map();
|
||||
const listeners = new Set();
|
||||
const commandDisposers = new Map();
|
||||
const running = new Map();
|
||||
const mountedSurfaces = new Set();
|
||||
const elementContexts = new WeakMap();
|
||||
const DANGER_LEVELS = new Set(['none', 'caution', 'destructive']);
|
||||
const CONCURRENCY_MODES = new Set(['single', 'parallel']);
|
||||
|
||||
let confirmationProvider = null;
|
||||
let auditSink = null;
|
||||
let domObserver = null;
|
||||
|
||||
const normalizeStringList = value => Array.isArray(value)
|
||||
? [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))]
|
||||
: [];
|
||||
|
||||
const normalizeNumber = (value, fallback = 0) => {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
};
|
||||
|
||||
function normalizeSelection(value) {
|
||||
if (!value || typeof value !== 'object') return Object.freeze({ min: 0, max: null });
|
||||
const min = Math.max(0, Math.floor(normalizeNumber(value.min, 0)));
|
||||
const maxCandidate = value.max === null || value.max === undefined ? null : Math.max(min, Math.floor(normalizeNumber(value.max, min)));
|
||||
return Object.freeze({ min, max: maxCandidate });
|
||||
}
|
||||
|
||||
function normalizeConfirmation(value, action) {
|
||||
if (value === false && action.danger !== 'destructive') return null;
|
||||
const source = value && typeof value === 'object' ? value : {};
|
||||
if (value !== true && !source.title && !source.message && action.danger !== 'destructive') return null;
|
||||
return Object.freeze({
|
||||
title: source.title ?? (action.danger === 'destructive' ? `${action.title} bestätigen` : action.title),
|
||||
message: source.message ?? (action.danger === 'destructive'
|
||||
? 'Diese Aktion kann Daten oder einen Betriebszustand dauerhaft verändern. Prüfe den Kontext und bestätige bewusst.'
|
||||
: 'Möchtest du diese Aktion ausführen?'),
|
||||
confirmLabel: source.confirmLabel ?? (action.danger === 'destructive' ? 'Verbindlich ausführen' : 'Ausführen'),
|
||||
cancelLabel: source.cancelLabel ?? 'Abbrechen',
|
||||
phrase: source.phrase ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAction(input) {
|
||||
if (!input || typeof input !== 'object') throw new TypeError('Action muss ein Objekt sein.');
|
||||
const id = String(input.id || '').trim();
|
||||
const title = String(input.title || '').trim();
|
||||
if (!id || !title) throw new TypeError('Action benötigt id und title.');
|
||||
if (typeof input.handler !== 'function') throw new TypeError(`Action ${id} benötigt einen handler.`);
|
||||
|
||||
const danger = DANGER_LEVELS.has(input.danger) ? input.danger : (input.danger === true ? 'destructive' : 'none');
|
||||
const concurrency = CONCURRENCY_MODES.has(input.concurrency) ? input.concurrency : 'single';
|
||||
const base = {
|
||||
id,
|
||||
title,
|
||||
description: String(input.description || ''),
|
||||
icon: String(input.icon || '•'),
|
||||
group: String(input.group || 'Allgemein'),
|
||||
aliases: normalizeStringList(input.aliases),
|
||||
keywords: normalizeStringList(input.keywords),
|
||||
surfaces: normalizeStringList(input.surfaces),
|
||||
shortcut: String(input.shortcut || '').trim(),
|
||||
permission: input.permission ?? null,
|
||||
danger,
|
||||
priority: normalizeNumber(input.priority, 0),
|
||||
visible: input.visible ?? null,
|
||||
enabled: input.enabled ?? null,
|
||||
disabledReason: String(input.disabledReason || ''),
|
||||
contexts: normalizeStringList(input.contexts),
|
||||
selection: normalizeSelection(input.selection),
|
||||
concurrency,
|
||||
allowInInput: input.allowInInput === true,
|
||||
handler: input.handler,
|
||||
rollback: typeof input.rollback === 'function' ? input.rollback : null,
|
||||
commandId: input.commandId ? String(input.commandId) : `action:${id}`,
|
||||
};
|
||||
return Object.freeze({ ...base, confirm: normalizeConfirmation(input.confirm, base) });
|
||||
}
|
||||
|
||||
const emit = (type, detail = {}) => {
|
||||
const payload = Object.freeze({ ...detail });
|
||||
window.dispatchEvent(new CustomEvent(`vendoo:${type}`, { detail: payload }));
|
||||
listeners.forEach(listener => {
|
||||
try { listener({ type, detail: payload }); } catch {}
|
||||
});
|
||||
if (typeof auditSink === 'function') {
|
||||
try { auditSink({ type, detail: payload }); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
function evaluateRule(rule, context, fallback) {
|
||||
if (typeof rule === 'function') {
|
||||
try { return Boolean(rule(context)); } catch { return false; }
|
||||
}
|
||||
if (typeof rule === 'boolean') return rule;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function evaluatePermission(permission, context) {
|
||||
if (permission === null || permission === undefined || permission === '') return true;
|
||||
if (typeof permission === 'function') {
|
||||
try { return Boolean(permission(context)); } catch { return false; }
|
||||
}
|
||||
if (typeof permission === 'boolean') return permission;
|
||||
const resolver = context?.canPermission || window.VendooPermissions?.can;
|
||||
if (typeof resolver !== 'function') return false;
|
||||
try {
|
||||
if (Array.isArray(permission)) return permission.every(value => resolver.call(window.VendooPermissions, value, context));
|
||||
return Boolean(resolver.call(window.VendooPermissions, permission, context));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectionCount(context) {
|
||||
if (Number.isFinite(Number(context?.selectionCount))) return Math.max(0, Number(context.selectionCount));
|
||||
if (Array.isArray(context?.selection)) return context.selection.length;
|
||||
if (context?.selection instanceof Set || context?.selection instanceof Map) return context.selection.size;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function matchesContext(action, context) {
|
||||
if (!action.contexts.length) return true;
|
||||
const supplied = new Set([
|
||||
...normalizeStringList(context?.contexts),
|
||||
...(context?.scope ? [String(context.scope)] : []),
|
||||
...(context?.view ? [String(context.view)] : []),
|
||||
]);
|
||||
return action.contexts.some(value => supplied.has(value));
|
||||
}
|
||||
|
||||
function resolveAction(actionOrId, context = {}) {
|
||||
const action = typeof actionOrId === 'string' ? registry.get(actionOrId) : actionOrId;
|
||||
if (!action) return null;
|
||||
const contextMatches = matchesContext(action, context);
|
||||
const permissionGranted = evaluatePermission(action.permission, context);
|
||||
const visible = contextMatches && permissionGranted && evaluateRule(action.visible, context, true);
|
||||
const count = selectionCount(context);
|
||||
const selectionValid = count >= action.selection.min && (action.selection.max === null || count <= action.selection.max);
|
||||
const isRunning = running.has(action.id);
|
||||
const enabled = visible
|
||||
&& selectionValid
|
||||
&& !(action.concurrency === 'single' && isRunning)
|
||||
&& evaluateRule(action.enabled, context, true);
|
||||
|
||||
let disabledReason = '';
|
||||
if (!enabled) {
|
||||
if (!visible) disabledReason = action.disabledReason || 'Diese Aktion ist im aktuellen Kontext nicht verfügbar.';
|
||||
else if (!selectionValid) {
|
||||
if (count < action.selection.min) disabledReason = action.disabledReason || `Mindestens ${action.selection.min} Auswahl erforderlich.`;
|
||||
else disabledReason = action.disabledReason || `Höchstens ${action.selection.max} Elemente können gleichzeitig verarbeitet werden.`;
|
||||
} else if (isRunning) disabledReason = 'Diese Aktion wird bereits ausgeführt.';
|
||||
else disabledReason = action.disabledReason || 'Diese Aktion ist derzeit nicht verfügbar.';
|
||||
}
|
||||
|
||||
return Object.freeze({ visible, enabled, disabledReason, running: isRunning, selectionCount: count });
|
||||
}
|
||||
|
||||
function list({ surface = null, group = null, context = {}, includeDisabled = true, includeHidden = false } = {}) {
|
||||
return [...registry.values()]
|
||||
.map(action => Object.freeze({ ...action, ...resolveAction(action, context) }))
|
||||
.filter(action => (!surface || action.surfaces.includes(surface))
|
||||
&& (!group || action.group === group)
|
||||
&& (includeHidden || action.visible)
|
||||
&& (includeDisabled || action.enabled))
|
||||
.sort((left, right) => right.priority - left.priority
|
||||
|| left.group.localeCompare(right.group, 'de-DE', { sensitivity: 'base' })
|
||||
|| left.title.localeCompare(right.title, 'de-DE', { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
function syncCommandAdapter(action, { preserveExisting = false } = {}) {
|
||||
if (!preserveExisting) {
|
||||
commandDisposers.get(action.id)?.();
|
||||
commandDisposers.delete(action.id);
|
||||
}
|
||||
if (!action.surfaces.includes('command-palette') || !window.VendooCommands?.register) return;
|
||||
|
||||
const dispose = window.VendooCommands.register({
|
||||
id: action.commandId,
|
||||
title: action.title,
|
||||
description: action.description,
|
||||
icon: action.icon,
|
||||
group: action.group,
|
||||
aliases: action.aliases,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
kind: 'action',
|
||||
visible: context => Boolean(resolveAction(action, context)?.visible),
|
||||
enabled: context => Boolean(resolveAction(action, context)?.enabled),
|
||||
disabledReason: action.disabledReason || 'Diese Aktion ist im aktuellen Kontext nicht verfügbar.',
|
||||
execute: context => {
|
||||
void execute(action.id, { ...context, source: context?.source || 'command-palette' });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
commandDisposers.set(action.id, dispose);
|
||||
}
|
||||
|
||||
function register(input, { replace = false } = {}) {
|
||||
const action = normalizeAction(input);
|
||||
if (registry.has(action.id) && !replace) throw new Error(`Action-ID ist bereits registriert: ${action.id}`);
|
||||
if (registry.has(action.id)) unregister(action.id);
|
||||
registry.set(action.id, action);
|
||||
syncCommandAdapter(action);
|
||||
emit('actions-changed', { actionId: action.id, operation: 'register' });
|
||||
scheduleRefresh();
|
||||
return () => unregister(action.id);
|
||||
}
|
||||
|
||||
function registerMany(actions, options = {}) {
|
||||
if (!Array.isArray(actions)) throw new TypeError('registerMany erwartet ein Array.');
|
||||
const disposers = actions.map(action => register(action, options));
|
||||
return () => disposers.reverse().forEach(dispose => dispose());
|
||||
}
|
||||
|
||||
function unregister(id) {
|
||||
const actionId = String(id);
|
||||
const removed = registry.delete(actionId);
|
||||
commandDisposers.get(actionId)?.();
|
||||
commandDisposers.delete(actionId);
|
||||
if (removed) {
|
||||
emit('actions-changed', { actionId, operation: 'unregister' });
|
||||
scheduleRefresh();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function resolveText(value, context, fallback = '') {
|
||||
if (typeof value === 'function') {
|
||||
try { return String(value(context) ?? fallback); } catch { return fallback; }
|
||||
}
|
||||
return String(value ?? fallback);
|
||||
}
|
||||
|
||||
function createElement(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function requestDefaultConfirmation(action, context) {
|
||||
if (!document.body || typeof document.createElement !== 'function') return Promise.resolve(false);
|
||||
const config = action.confirm || normalizeConfirmation(true, action);
|
||||
return new Promise(resolve => {
|
||||
const overlay = createElement('div', 'vd-action-confirm-overlay');
|
||||
overlay.setAttribute('role', 'presentation');
|
||||
const dialog = createElement('section', 'vd-action-confirm-dialog');
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
dialog.setAttribute('aria-labelledby', 'vd-action-confirm-title');
|
||||
dialog.setAttribute('aria-describedby', 'vd-action-confirm-message');
|
||||
dialog.dataset.tone = action.danger;
|
||||
|
||||
const header = createElement('header', 'vd-action-confirm-header');
|
||||
const marker = createElement('span', 'vd-action-confirm-marker', action.danger === 'destructive' ? '!' : '?');
|
||||
marker.setAttribute('aria-hidden', 'true');
|
||||
const heading = createElement('div', 'vd-action-confirm-heading');
|
||||
const title = createElement('h2', '', resolveText(config.title, context, `${action.title} bestätigen`));
|
||||
title.id = 'vd-action-confirm-title';
|
||||
const risk = createElement('span', 'vd-action-confirm-risk', action.danger === 'destructive' ? 'Destruktive Aktion' : 'Bestätigung erforderlich');
|
||||
heading.append(risk, title);
|
||||
header.append(marker, heading);
|
||||
|
||||
const message = createElement('p', 'vd-action-confirm-message', resolveText(config.message, context));
|
||||
message.id = 'vd-action-confirm-message';
|
||||
const phrase = resolveText(config.phrase, context).trim();
|
||||
let phraseInput = null;
|
||||
const body = createElement('div', 'vd-action-confirm-body');
|
||||
body.append(message);
|
||||
if (phrase) {
|
||||
const label = createElement('label', 'vd-action-confirm-phrase');
|
||||
label.append(createElement('span', '', `Zur Bestätigung „${phrase}“ eingeben`));
|
||||
phraseInput = createElement('input');
|
||||
phraseInput.type = 'text';
|
||||
phraseInput.autocomplete = 'off';
|
||||
phraseInput.spellcheck = false;
|
||||
label.append(phraseInput);
|
||||
body.append(label);
|
||||
}
|
||||
|
||||
const footer = createElement('footer', 'vd-action-confirm-footer');
|
||||
const cancel = createElement('button', 'vd-action-confirm-cancel', resolveText(config.cancelLabel, context, 'Abbrechen'));
|
||||
cancel.type = 'button';
|
||||
const confirm = createElement('button', 'vd-action-confirm-submit', resolveText(config.confirmLabel, context, 'Ausführen'));
|
||||
confirm.type = 'button';
|
||||
if (phraseInput) confirm.disabled = true;
|
||||
footer.append(cancel, confirm);
|
||||
dialog.append(header, body, footer);
|
||||
overlay.append(dialog);
|
||||
document.body.append(overlay);
|
||||
document.body.classList.add('vd-action-confirm-open');
|
||||
|
||||
const previousFocus = document.activeElement;
|
||||
let settled = false;
|
||||
const finish = value => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
document.removeEventListener('keydown', onKeydown, true);
|
||||
overlay.remove();
|
||||
document.body.classList.remove('vd-action-confirm-open');
|
||||
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus({ preventScroll: true });
|
||||
resolve(value);
|
||||
};
|
||||
const focusables = () => [...dialog.querySelectorAll('button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex="-1"])')];
|
||||
const onKeydown = event => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const items = focusables();
|
||||
if (!items.length) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeydown, true);
|
||||
cancel.addEventListener('click', () => finish(false));
|
||||
confirm.addEventListener('click', () => finish(true));
|
||||
overlay.addEventListener('mousedown', event => { if (event.target === overlay) finish(false); });
|
||||
phraseInput?.addEventListener('input', () => { confirm.disabled = phraseInput.value.trim() !== phrase; });
|
||||
requestAnimationFrame(() => (phraseInput || cancel).focus({ preventScroll: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function requestConfirmation(action, context) {
|
||||
if (!action.confirm && action.danger !== 'destructive') return true;
|
||||
const provider = confirmationProvider || requestDefaultConfirmation;
|
||||
try { return Boolean(await provider(action, context)); } catch { return false; }
|
||||
}
|
||||
|
||||
async function execute(id, context = {}) {
|
||||
const action = registry.get(String(id));
|
||||
if (!action) return false;
|
||||
const status = resolveAction(action, context);
|
||||
if (!status?.visible || !status.enabled) {
|
||||
emit('action-blocked', { actionId: action.id, source: context?.source || 'api', reason: status?.disabledReason || 'Aktion nicht verfügbar.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(await requestConfirmation(action, context))) {
|
||||
emit('action-cancelled', { actionId: action.id, source: context?.source || 'api', reason: 'confirmation-declined' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
if (action.concurrency === 'single') running.set(action.id, startedAt);
|
||||
emit('action-executing', { actionId: action.id, source: context?.source || 'api', danger: action.danger });
|
||||
scheduleRefresh();
|
||||
|
||||
try {
|
||||
const result = await action.handler(Object.freeze({ ...context, actionId: action.id }));
|
||||
if (result === false) {
|
||||
emit('action-cancelled', { actionId: action.id, source: context?.source || 'api', reason: 'handler-declined', durationMs: Date.now() - startedAt });
|
||||
return false;
|
||||
}
|
||||
emit('action-executed', { actionId: action.id, source: context?.source || 'api', durationMs: Date.now() - startedAt });
|
||||
return true;
|
||||
} catch (error) {
|
||||
let rolledBack = false;
|
||||
if (action.rollback) {
|
||||
try {
|
||||
await action.rollback(Object.freeze({ ...context, actionId: action.id, error }));
|
||||
rolledBack = true;
|
||||
emit('action-rolled-back', { actionId: action.id, source: context?.source || 'api' });
|
||||
} catch (rollbackError) {
|
||||
emit('action-rollback-failed', {
|
||||
actionId: action.id,
|
||||
source: context?.source || 'api',
|
||||
message: rollbackError?.message || String(rollbackError),
|
||||
});
|
||||
}
|
||||
}
|
||||
emit('action-failed', {
|
||||
actionId: action.id,
|
||||
source: context?.source || 'api',
|
||||
message: error?.message || String(error),
|
||||
rolledBack,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
if (action.concurrency === 'single') running.delete(action.id);
|
||||
scheduleRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function parseElementContext(element) {
|
||||
const stored = elementContexts.get(element) || {};
|
||||
let declared = {};
|
||||
const raw = element.dataset.vendooActionContext;
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) declared = parsed;
|
||||
} catch {}
|
||||
}
|
||||
const scope = element.closest?.('[data-vendoo-action-scope]')?.dataset.vendooActionScope;
|
||||
return {
|
||||
...stored,
|
||||
...declared,
|
||||
scope: declared.scope || stored.scope || scope || window.VendooNavigation?.currentView || 'global',
|
||||
view: declared.view || stored.view || window.VendooNavigation?.currentView || null,
|
||||
source: declared.source || stored.source || element.dataset.vendooActionSource || 'dom',
|
||||
trigger: element,
|
||||
};
|
||||
}
|
||||
|
||||
function syncElement(element) {
|
||||
const actionId = element?.dataset?.vendooAction;
|
||||
if (!actionId) return;
|
||||
const action = registry.get(actionId);
|
||||
const status = action ? resolveAction(action, parseElementContext(element)) : null;
|
||||
const enabled = Boolean(status?.enabled);
|
||||
element.setAttribute('aria-disabled', enabled ? 'false' : 'true');
|
||||
if ('disabled' in element) element.disabled = !enabled;
|
||||
element.classList.toggle('vd-action-running', Boolean(status?.running));
|
||||
element.classList.toggle('vd-action-destructive', action?.danger === 'destructive');
|
||||
element.classList.toggle('vd-action-caution', action?.danger === 'caution');
|
||||
if (!element.dataset.vendooActionOriginalTitle) element.dataset.vendooActionOriginalTitle = element.getAttribute('title') || '';
|
||||
const originalTitle = element.dataset.vendooActionOriginalTitle;
|
||||
element.title = enabled ? originalTitle : (status?.disabledReason || 'Aktion nicht verfügbar.');
|
||||
}
|
||||
|
||||
function refreshDom(root = document) {
|
||||
root.querySelectorAll?.('[data-vendoo-action]').forEach(syncElement);
|
||||
if (root.matches?.('[data-vendoo-action]')) syncElement(root);
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => refresh());
|
||||
else refresh();
|
||||
}
|
||||
|
||||
function refresh(actionId = null) {
|
||||
if (actionId && registry.has(actionId)) syncCommandAdapter(registry.get(actionId), { preserveExisting: true });
|
||||
else if (!actionId) registry.forEach(action => syncCommandAdapter(action, { preserveExisting: true }));
|
||||
refreshDom();
|
||||
mountedSurfaces.forEach(entry => {
|
||||
try { entry.render(); } catch {}
|
||||
});
|
||||
emit('action-state-changed', { actionId });
|
||||
}
|
||||
|
||||
function createActionButton(action, context, variant) {
|
||||
const button = createElement('button', 'vd-action-button');
|
||||
button.type = 'button';
|
||||
button.dataset.vendooAction = action.id;
|
||||
button.dataset.actionVariant = variant;
|
||||
button.dataset.actionDanger = action.danger;
|
||||
elementContexts.set(button, context);
|
||||
|
||||
const icon = createElement('span', 'vd-action-button-icon', action.icon);
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
const copy = createElement('span', 'vd-action-button-copy');
|
||||
copy.append(createElement('strong', '', action.title));
|
||||
if (action.description && variant !== 'toolbar') copy.append(createElement('small', '', action.description));
|
||||
button.append(icon, copy);
|
||||
if (action.shortcut && variant !== 'sheet') button.append(createElement('kbd', '', action.shortcut));
|
||||
syncElement(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
function renderSurface(container, { surface, context = {}, variant = 'toolbar', includeDisabled = true, emptyText = '' } = {}) {
|
||||
if (!container || typeof container.replaceChildren !== 'function') throw new TypeError('renderSurface benötigt einen DOM-Container.');
|
||||
if (!surface) throw new TypeError('renderSurface benötigt einen surface-Namen.');
|
||||
const actions = list({ surface, context, includeDisabled });
|
||||
container.classList.add('vd-action-surface');
|
||||
container.dataset.actionSurface = surface;
|
||||
container.dataset.actionVariant = variant;
|
||||
container.replaceChildren();
|
||||
if (!actions.length && emptyText) container.append(createElement('span', 'vd-action-surface-empty', emptyText));
|
||||
else actions.forEach(action => container.append(createActionButton(action, context, variant)));
|
||||
return actions;
|
||||
}
|
||||
|
||||
function mountSurface(container, options = {}) {
|
||||
const contextProvider = typeof options.context === 'function' ? options.context : () => (options.context || {});
|
||||
const entry = {
|
||||
container,
|
||||
render: () => renderSurface(container, { ...options, context: contextProvider() }),
|
||||
};
|
||||
mountedSurfaces.add(entry);
|
||||
entry.render();
|
||||
return () => {
|
||||
mountedSurfaces.delete(entry);
|
||||
container.replaceChildren();
|
||||
container.classList.remove('vd-action-surface');
|
||||
delete container.dataset.actionSurface;
|
||||
delete container.dataset.actionVariant;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeShortcut(value) {
|
||||
const aliases = { control: 'Ctrl', ctrl: 'Ctrl', cmd: 'Meta', command: 'Meta', meta: 'Meta', option: 'Alt', alt: 'Alt', shift: 'Shift' };
|
||||
const parts = String(value || '').split('+').map(part => part.trim()).filter(Boolean);
|
||||
const modifiers = [];
|
||||
let key = '';
|
||||
parts.forEach(part => {
|
||||
const normalized = aliases[part.toLocaleLowerCase('en-US')];
|
||||
if (normalized) modifiers.push(normalized);
|
||||
else key = part.length === 1 ? part.toLocaleUpperCase('en-US') : part;
|
||||
});
|
||||
return [...new Set(modifiers)].sort((a, b) => ['Ctrl', 'Alt', 'Shift', 'Meta'].indexOf(a) - ['Ctrl', 'Alt', 'Shift', 'Meta'].indexOf(b)).concat(key || []).join('+');
|
||||
}
|
||||
|
||||
function eventShortcut(event) {
|
||||
const parts = [];
|
||||
if (event.ctrlKey) parts.push('Ctrl');
|
||||
if (event.altKey) parts.push('Alt');
|
||||
if (event.shiftKey) parts.push('Shift');
|
||||
if (event.metaKey) parts.push('Meta');
|
||||
const key = event.key.length === 1 ? event.key.toLocaleUpperCase('en-US') : event.key;
|
||||
if (!['Control', 'Alt', 'Shift', 'Meta'].includes(key)) parts.push(key);
|
||||
return normalizeShortcut(parts.join('+'));
|
||||
}
|
||||
|
||||
function isEditableTarget(target) {
|
||||
return Boolean(target?.closest?.('input, textarea, select, [contenteditable="true"], [role="textbox"]'));
|
||||
}
|
||||
|
||||
function handleShortcut(event) {
|
||||
if (event.defaultPrevented) return;
|
||||
const shortcut = eventShortcut(event);
|
||||
if (!shortcut) return;
|
||||
const context = { source: 'shortcut', event, scope: window.VendooNavigation?.currentView || 'global', view: window.VendooNavigation?.currentView || null };
|
||||
const candidates = list({ surface: 'shortcut', context, includeDisabled: false })
|
||||
.filter(action => normalizeShortcut(action.shortcut) === shortcut)
|
||||
.filter(action => action.allowInInput || !isEditableTarget(event.target));
|
||||
if (!candidates.length) return;
|
||||
const highest = candidates[0].priority;
|
||||
const winners = candidates.filter(action => action.priority === highest);
|
||||
event.preventDefault();
|
||||
if (winners.length !== 1) {
|
||||
emit('action-shortcut-conflict', { shortcut, actionIds: winners.map(action => action.id) });
|
||||
return;
|
||||
}
|
||||
void execute(winners[0].id, context);
|
||||
}
|
||||
|
||||
function bindDom() {
|
||||
document.addEventListener('click', event => {
|
||||
const trigger = event.target?.closest?.('[data-vendoo-action]');
|
||||
if (!trigger) return;
|
||||
const actionId = trigger.dataset.vendooAction;
|
||||
const status = resolveAction(actionId, parseElementContext(trigger));
|
||||
if (!status?.enabled) {
|
||||
event.preventDefault();
|
||||
emit('action-blocked', { actionId, source: 'dom', reason: status?.disabledReason || 'Aktion nicht verfügbar.' });
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void execute(actionId, parseElementContext(trigger));
|
||||
});
|
||||
document.addEventListener('keydown', handleShortcut);
|
||||
['vendoo:modules-changed', 'vendoo:navigation-changed', 'vendoo:selection-changed', 'vendoo:permissions-changed']
|
||||
.forEach(type => window.addEventListener(type, () => scheduleRefresh()));
|
||||
|
||||
if ('MutationObserver' in window && document.body) {
|
||||
domObserver = new MutationObserver(mutations => {
|
||||
mutations.forEach(mutation => mutation.addedNodes.forEach(node => {
|
||||
if (node.nodeType === 1) refreshDom(node);
|
||||
}));
|
||||
});
|
||||
domObserver.observe(document.body, { childList: true, subtree: true });
|
||||
window.addEventListener('beforeunload', () => domObserver?.disconnect(), { once: true });
|
||||
}
|
||||
refreshDom();
|
||||
}
|
||||
|
||||
window.VendooActions = Object.freeze({
|
||||
register,
|
||||
registerMany,
|
||||
unregister,
|
||||
list,
|
||||
get: id => registry.get(String(id)) || null,
|
||||
resolve: (id, context = {}) => resolveAction(String(id), context),
|
||||
canExecute: (id, context = {}) => Boolean(resolveAction(String(id), context)?.enabled),
|
||||
execute,
|
||||
refresh,
|
||||
renderSurface,
|
||||
mountSurface,
|
||||
normalizeShortcut,
|
||||
setConfirmationProvider(provider) {
|
||||
if (provider !== null && typeof provider !== 'function') throw new TypeError('Confirmation Provider muss eine Funktion oder null sein.');
|
||||
confirmationProvider = provider;
|
||||
},
|
||||
setAuditSink(sink) {
|
||||
if (sink !== null && typeof sink !== 'function') throw new TypeError('Audit Sink muss eine Funktion oder null sein.');
|
||||
auditSink = sink;
|
||||
},
|
||||
get running() { return [...running.keys()]; },
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== 'function') throw new TypeError('Listener muss eine Funktion sein.');
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
});
|
||||
|
||||
bindDom();
|
||||
emit('actions-ready', { count: registry.size });
|
||||
})();
|
||||
+230
-21
@@ -1,4 +1,4 @@
|
||||
const VENDOO_UI_BUILD = '1.41.2';
|
||||
const VENDOO_UI_BUILD = '1.43.0';
|
||||
|
||||
const state = {
|
||||
photos: [],
|
||||
@@ -282,6 +282,44 @@ function setupResponsiveContracts() {
|
||||
}
|
||||
}
|
||||
|
||||
const moduleUiState = new Map();
|
||||
|
||||
function isUiModuleEnabled(id) {
|
||||
return !id || moduleUiState.get(id) !== false;
|
||||
}
|
||||
|
||||
function applyModuleVisibility(root = document) {
|
||||
root.querySelectorAll?.('[data-module], [data-module-panel]').forEach(element => {
|
||||
const moduleId = element.dataset.module || element.dataset.modulePanel;
|
||||
const visible = isUiModuleEnabled(moduleId);
|
||||
element.classList.toggle('hidden', !visible);
|
||||
element.setAttribute('aria-hidden', visible ? 'false' : 'true');
|
||||
if ('disabled' in element && !visible) element.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
function applyModuleNavigation(payload = {}) {
|
||||
moduleUiState.clear();
|
||||
for (const module of payload.modules || []) moduleUiState.set(module.id, module.enabled === true);
|
||||
applyModuleVisibility(document);
|
||||
const active = document.querySelector('.tab-content.active');
|
||||
const activeButton = active ? document.querySelector(`.nav-item[data-tab="${active.id}"]`) : null;
|
||||
if (activeButton?.classList.contains('hidden')) switchToTab('dashboard');
|
||||
}
|
||||
|
||||
async function refreshModuleNavigation() {
|
||||
try {
|
||||
const payload = await api('GET', '/api/modules/navigation', null, { retry: false, timeoutMs: 10000 });
|
||||
applyModuleNavigation(payload);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.warn('[Vendoo] Modulnavigation konnte nicht geladen werden:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('vendoo:modules-changed', () => refreshModuleNavigation());
|
||||
|
||||
async function init() {
|
||||
document.documentElement.dataset.vendooBuild = VENDOO_UI_BUILD;
|
||||
console.info(`[Vendoo] UI-Build ${VENDOO_UI_BUILD}`);
|
||||
@@ -295,10 +333,16 @@ async function init() {
|
||||
if (meData.setupMode) { window.location.href = '/login.html'; return; }
|
||||
} catch {}
|
||||
|
||||
// Modulstatus immer zuerst laden: Ein deaktiviertes Modul darf weder seine
|
||||
// Navigation anzeigen noch den App-Start durch eine gesperrte API blockieren.
|
||||
const moduleNavigation = await api('GET', '/api/modules/navigation');
|
||||
applyModuleNavigation(moduleNavigation);
|
||||
const aiEnabled = isUiModuleEnabled('vendoo.ai');
|
||||
|
||||
const [platforms, settings, providers, templates] = await Promise.all([
|
||||
api('GET', '/api/platforms'),
|
||||
api('GET', '/api/settings'),
|
||||
api('GET', '/api/providers'),
|
||||
aiEnabled ? api('GET', '/api/providers') : Promise.resolve([]),
|
||||
api('GET', '/api/templates'),
|
||||
]);
|
||||
|
||||
@@ -1704,6 +1748,12 @@ function closeMobileSidebar({ restoreFocus = false } = {}) {
|
||||
}
|
||||
|
||||
function switchToTab(tabId, adminTab) {
|
||||
const requestedNav = document.querySelector(`.nav-item[data-tab="${tabId}"]`);
|
||||
if (requestedNav?.classList.contains('hidden')) {
|
||||
toast('Dieses Modul ist derzeit deaktiviert.');
|
||||
tabId = 'dashboard';
|
||||
adminTab = null;
|
||||
}
|
||||
if (tabId !== 'history' && listingEditLock.listingId) releaseListingLock({ quiet: true });
|
||||
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
@@ -2464,7 +2514,8 @@ function showListing(listing, platformHtml) {
|
||||
document.getElementById('result-tags').value = tags;
|
||||
const plat = state.platforms.find(p => p.id === state.platform);
|
||||
document.getElementById('tags-label').textContent = plat?.fields.includes('hashtags') ? 'Hashtags' : 'Tags';
|
||||
document.getElementById('save-btn').textContent = listing.id ? 'Änderungen speichern' : 'Speichern & Hinzufügen';
|
||||
const saveButton = document.getElementById('save-btn');
|
||||
if (saveButton) { saveButton.textContent = listing.id ? 'Änderungen speichern' : 'Artikel speichern'; saveButton.disabled = false; }
|
||||
document.getElementById('generator-preview-empty')?.classList.add('hidden');
|
||||
document.getElementById('result-section').classList.remove('hidden');
|
||||
const previewStatus = document.getElementById('generator-preview-status');
|
||||
@@ -2568,7 +2619,8 @@ function resetGenerator() {
|
||||
document.getElementById('meta-section').classList.add('hidden');
|
||||
document.getElementById('variants-section').classList.add('hidden');
|
||||
document.getElementById('generator-preview-empty')?.classList.remove('hidden');
|
||||
document.getElementById('save-btn').textContent = 'Speichern & Hinzufügen';
|
||||
const saveButton = document.getElementById('save-btn');
|
||||
if (saveButton) { saveButton.textContent = 'Speichern'; saveButton.disabled = true; }
|
||||
document.getElementById('generate-btn').disabled = true;
|
||||
renderAiModelGrid('generator', []);
|
||||
setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral');
|
||||
@@ -3397,13 +3449,13 @@ async function showDetail(id) {
|
||||
<button id="detail-download-photos" class="vd-button compact" type="button">Fotos herunterladen</button>
|
||||
</div>
|
||||
<p class="detail-photo-help">Vorschaubilder ziehen, um die Reihenfolge zu ändern. Das erste Bild ist das Cover.</p>
|
||||
<section class="ai-model-card detail-ai-model-card">
|
||||
<section class="ai-model-card detail-ai-model-card hidden" data-module-panel="vendoo.product-image-tools">
|
||||
<div class="ai-model-card-head">
|
||||
<div>
|
||||
<span class="generator-panel-index">AI</span>
|
||||
<div>
|
||||
<h4>Produktbild-Werkzeuge</h4>
|
||||
<p>Erzeuge Ghost-Mannequin- oder Flatlay-Varianten aus den aktuellen Listing-Fotos. Freie Prompt-Bilder werden im FLUX Studio erstellt.</p>
|
||||
<h4>Produktbild Studio</h4>
|
||||
<p>Optionales Modul für kontrollierte Ghost-Mannequin- und Flatlay-Varianten aus den aktuellen Listing-Fotos.</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="generator-beta">Safe</span>
|
||||
@@ -3514,6 +3566,8 @@ async function showDetail(id) {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
applyModuleVisibility(document.getElementById('detail-content'));
|
||||
|
||||
const lockResult = await acquireListingLock(numericId);
|
||||
if (!userCan('listings.edit') || lockResult.readOnly) {
|
||||
document.querySelectorAll('#history-detail input, #history-detail textarea, #history-detail select').forEach(element => { element.disabled = true; });
|
||||
@@ -3545,16 +3599,18 @@ async function showDetail(id) {
|
||||
if (element) { element.disabled = true; element.setAttribute('aria-disabled', 'true'); }
|
||||
});
|
||||
}
|
||||
document.getElementById('detail-ai-model-btn')?.addEventListener('click', runDetailAiModelPhotos);
|
||||
document.getElementById('detail-ai-model-prompt-btn')?.addEventListener('click', () => openAiPromptModal('detail'));
|
||||
document.getElementById('detail-ai-model-mode')?.addEventListener('change', () => updateAiModeControls('detail'));
|
||||
updateAiModeControls('detail');
|
||||
if (isUiModuleEnabled('vendoo.product-image-tools')) {
|
||||
document.getElementById('detail-ai-model-btn')?.addEventListener('click', runDetailAiModelPhotos);
|
||||
document.getElementById('detail-ai-model-prompt-btn')?.addEventListener('click', () => openAiPromptModal('detail'));
|
||||
document.getElementById('detail-ai-model-mode')?.addEventListener('change', () => updateAiModeControls('detail'));
|
||||
updateAiModeControls('detail');
|
||||
renderAiModelGrid('detail', []);
|
||||
loadDetailAiModelPhotos(numericId);
|
||||
loadDetailAiModelGenerations(numericId);
|
||||
}
|
||||
renderDetailPhotos();
|
||||
renderAiModelGrid('detail', []);
|
||||
addQuillTooltips();
|
||||
refreshDetailPlatformPreview();
|
||||
loadDetailAiModelPhotos(numericId);
|
||||
loadDetailAiModelGenerations(numericId);
|
||||
|
||||
if (typeof QRious !== 'undefined') {
|
||||
new QRious({ element: document.getElementById('detail-qr-canvas'), value: JSON.stringify({ sku: skuVal, id: l.id }), size: 118, level: 'M' });
|
||||
@@ -8107,6 +8163,92 @@ async function loadOperationsExtensions() {
|
||||
catch (error) { const box = document.getElementById('operations-extension-clients'); if (box) box.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
|
||||
}
|
||||
|
||||
function deploymentStateLabel(state) {
|
||||
return ({
|
||||
checking: 'Prüfung', backup_pending: 'Backup geplant', backup_running: 'Backup läuft',
|
||||
backup_verified: 'Backup verifiziert', deploy_requested: 'Deployment angefordert',
|
||||
deploy_running: 'Deployment läuft', health_wait: 'Readiness wird geprüft',
|
||||
succeeded: 'Erfolgreich', failed: 'Fehlgeschlagen', rollback_required: 'Rollback/Prüfung erforderlich',
|
||||
})[state] || state || 'Unbekannt';
|
||||
}
|
||||
|
||||
function deploymentStateClass(state) {
|
||||
if (state === 'succeeded') return 'is-ok';
|
||||
if (state === 'failed' || state === 'rollback_required') return 'is-failed';
|
||||
return 'is-running';
|
||||
}
|
||||
|
||||
function renderProductionOverview(data) {
|
||||
const box = document.getElementById('deployment-production-overview');
|
||||
if (!box) return;
|
||||
const production = data.production || {};
|
||||
const readiness = production.readiness || {};
|
||||
const storage = production.storage || {};
|
||||
const lastBackup = production.last_backup;
|
||||
const flux = production.flux || {};
|
||||
const cards = [
|
||||
['Version', production.version || data.current_version || VENDOO_UI_BUILD, production.build_revision ? `Build ${String(production.build_revision).slice(0, 12)}` : 'Keine Build-Revision gemeldet', true],
|
||||
['Readiness', readiness.ok ? 'Bereit' : 'Nicht bereit', readiness.error || readiness.message || 'Interner Readiness-Check', !!readiness.ok],
|
||||
['Datenbank', production.database?.integrity === 'ok' ? 'Integrität OK' : String(production.database?.integrity || 'Unbekannt'), production.database?.path || 'Kein Pfad gemeldet', production.database?.integrity === 'ok'],
|
||||
['Persistente Daten', operationFormatBytes(storage.data?.bytes || 0), `${Number(storage.data?.files || 0)} Einträge · Uploads ${operationFormatBytes(storage.uploads?.bytes || 0)}`, true],
|
||||
['Letztes Backup', lastBackup ? (lastBackup.status === 'verified' ? 'Verifiziert' : lastBackup.status) : 'Nicht vorhanden', lastBackup ? `${operationFormatDate(lastBackup.created_at)} · ${operationFormatBytes(lastBackup.file_size)}` : 'Vor dem ersten Deployment erforderlich', lastBackup?.status === 'verified'],
|
||||
['FLUX Studio', flux.enabled ? 'Aktiv' : 'Deaktiviert', flux.enabled ? 'Produktionsgrenze prüfen' : 'Wie vorgesehen deaktiviert', !flux.enabled],
|
||||
];
|
||||
box.innerHTML = cards.map(([label, value, detail, ok]) => `<article class="deployment-production-item ${ok ? 'is-ok' : 'is-warning'}"><span data-vd-icon="${ok ? 'check-circle-2' : 'triangle-alert'}"></span><div><small>${escapeHtml(label)}</small><strong>${escapeHtml(String(value))}</strong><p>${escapeHtml(String(detail || ''))}</p></div></article>`).join('');
|
||||
|
||||
const checkBox = document.getElementById('deployment-production-check-result');
|
||||
const check = data.latest_production_check;
|
||||
if (checkBox && check) {
|
||||
checkBox.innerHTML = `<strong>${check.overall_status === 'passed' ? 'Produktionscheck bestanden' : check.overall_status === 'warning' ? 'Produktionscheck mit Hinweisen' : 'Produktionscheck fehlgeschlagen'}</strong><span>${Number(check.passed_count || 0)} bestanden · ${Number(check.warning_count || 0)} Hinweise · ${Number(check.failed_count || 0)} Fehler · ${operationFormatDate(check.created_at || check.verified_at)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDeploymentRuns(data) {
|
||||
const list = document.getElementById('deployment-run-list');
|
||||
if (!list) return;
|
||||
const runs = data.recent_runs || [];
|
||||
if (!runs.length) { list.innerHTML = '<div class="empty-state">Keine Deployment-Aufträge vorhanden.</div>'; return; }
|
||||
list.innerHTML = runs.map(run => {
|
||||
const lastEvent = run.events?.at?.(-1);
|
||||
const active = !['succeeded', 'failed'].includes(run.state);
|
||||
return `<article class="deployment-run ${deploymentStateClass(run.state)}">
|
||||
<div class="deployment-run-head"><span class="deployment-run-state">${escapeHtml(deploymentStateLabel(run.state))}</span><time>${operationFormatDate(run.created_at)}</time></div>
|
||||
<strong>${escapeHtml(run.current_version || '?')} → ${escapeHtml(run.target_version || '?')}</strong>
|
||||
<p>${escapeHtml(lastEvent?.message || run.error_message || run.reason || 'Deployment-Auftrag')}</p>
|
||||
<small>${escapeHtml(run.github_branch || 'main')}${run.backup_id ? ' · Backup verifiziert' : ''}${run.deployment_id ? ` · ID ${escapeHtml(String(run.deployment_id).slice(0, 18))}` : ''}</small>
|
||||
${active ? `<div class="deployment-run-actions">${run.state !== 'rollback_required' ? `<button type="button" class="secondary-btn compact" data-deployment-refresh="${escapeHtml(run.id)}">Status aktualisieren</button>` : ''}${data.trigger_mode === 'webhook' && ['deploy_requested','deploy_running','health_wait'].includes(run.state) ? `<button type="button" class="secondary-btn compact" data-deployment-confirm="${escapeHtml(run.id)}">Externen Abschluss bestätigen</button>` : ''}${run.state === 'rollback_required' ? `<button type="button" class="secondary-btn compact" data-deployment-resolve="${escapeHtml(run.id)}">Prüfung abschließen</button>` : ''}</div>` : ''}
|
||||
</article>`;
|
||||
}).join('');
|
||||
list.querySelectorAll('[data-deployment-refresh]').forEach(button => button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
try { await api('POST', `/api/admin/deployment/runs/${encodeURIComponent(button.dataset.deploymentRefresh)}/refresh`); await loadDeploymentStatus(); }
|
||||
catch (error) { toast(error.message); }
|
||||
finally { button.disabled = false; }
|
||||
}));
|
||||
list.querySelectorAll('[data-deployment-confirm]').forEach(button => button.addEventListener('click', async () => {
|
||||
const confirmation = prompt('Nur bestätigen, wenn Coolify das Deployment wirklich abgeschlossen hat. Exakt CONFIRM eingeben:');
|
||||
if (confirmation !== 'CONFIRM') return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api('POST', `/api/admin/deployment/runs/${encodeURIComponent(button.dataset.deploymentConfirm)}/confirm`, { confirmation, outcome: 'succeeded', message: 'Im Vendoo Update Center extern bestätigt.' });
|
||||
toast('Externer Abschluss wurde erfasst und Readiness erneut geprüft.');
|
||||
await loadDeploymentStatus();
|
||||
} catch (error) { toast(error.message); }
|
||||
finally { button.disabled = false; }
|
||||
}));
|
||||
list.querySelectorAll('[data-deployment-resolve]').forEach(button => button.addEventListener('click', async () => {
|
||||
const confirmation = prompt('Der Auftrag bleibt als fehlgeschlagen dokumentiert und die Deployment-Sperre wird aufgehoben. Exakt CONFIRM eingeben:');
|
||||
if (confirmation !== 'CONFIRM') return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api('POST', `/api/admin/deployment/runs/${encodeURIComponent(button.dataset.deploymentResolve)}/confirm`, { confirmation, outcome: 'failed', message: 'Rollback-/Fehlerprüfung manuell abgeschlossen.' });
|
||||
toast('Prüfung abgeschlossen. Ein neuer Deployment-Auftrag ist wieder möglich.');
|
||||
await loadDeploymentStatus();
|
||||
} catch (error) { toast(error.message); }
|
||||
finally { button.disabled = false; }
|
||||
}));
|
||||
}
|
||||
|
||||
function renderDeploymentStatus() {
|
||||
const data = operationsState.deployment || {};
|
||||
const setValue = (id, value) => { const field = document.getElementById(id); if (field && document.activeElement !== field) field.value = value ?? ''; };
|
||||
@@ -8114,9 +8256,14 @@ function renderDeploymentStatus() {
|
||||
setValue('deployment-coolify-url', data.coolify_url || '');
|
||||
setValue('deployment-resource-uuid', data.resource_uuid || '');
|
||||
setValue('deployment-trigger-mode', data.trigger_mode || 'webhook');
|
||||
setValue('deployment-status-endpoint', data.deployment_status_endpoint || '/api/v1/deployments/{deployment_id}');
|
||||
setValue('deployment-timeout-seconds', data.deployment_timeout_seconds || 900);
|
||||
setValue('deployment-poll-seconds', data.health_poll_seconds || 15);
|
||||
setValue('deployment-github-repository', data.github_repository || 'Masterluke77/vendoo');
|
||||
setValue('deployment-github-branch', data.github_branch || 'main');
|
||||
setValue('deployment-update-channel', data.update_channel || 'stable');
|
||||
const target = document.getElementById('deployment-target-version');
|
||||
if (target && document.activeElement !== target && !target.value) target.value = data.active_run?.target_version || data.current_version || '';
|
||||
const backup = document.getElementById('deployment-backup-before'); if (backup) backup.checked = data.backup_before_deploy !== false;
|
||||
const uploads = document.getElementById('deployment-backup-uploads'); if (uploads) uploads.checked = data.backup_uploads === true;
|
||||
const webhook = document.getElementById('deployment-webhook-url'); if (webhook && document.activeElement !== webhook) { webhook.value = ''; webhook.placeholder = data.deploy_webhook?.configured ? `Gespeichert: ${data.deploy_webhook.masked || '••••••••'}` : 'Coolify Deploy-Webhook eintragen'; }
|
||||
@@ -8124,7 +8271,11 @@ function renderDeploymentStatus() {
|
||||
const status = document.getElementById('deployment-status');
|
||||
if (status) status.innerHTML = `<strong>${data.configured ? 'Coolify ist konfiguriert' : 'Coolify noch nicht vollständig konfiguriert'}</strong><span>Provider: ${escapeHtml(data.provider || 'none')} · Trigger: ${escapeHtml(data.trigger_mode || 'webhook')} · Docker-Socket: ${data.docker_socket_access ? 'vorhanden' : 'nicht freigegeben'}</span>`;
|
||||
const action = document.getElementById('deployment-action-result');
|
||||
if (action && data.last_deployment) action.innerHTML = `<strong>${data.last_deployment.accepted ? 'Letzter Deployment-Auftrag angenommen' : 'Letzter Deployment-Auftrag fehlgeschlagen'}</strong><span>${operationFormatDate(data.last_deployment.requested_at)} · Branch ${escapeHtml(data.last_deployment.github_branch || 'main')} · HTTP ${escapeHtml(String(data.last_deployment.http_status || '—'))}</span>`;
|
||||
const last = data.recent_runs?.[0] || data.production?.last_deployment;
|
||||
if (action && last) action.innerHTML = `<strong>${escapeHtml(deploymentStateLabel(last.state))}</strong><span>${operationFormatDate(last.created_at || last.requested_at)} · ${escapeHtml(last.current_version || data.current_version || '?')} → ${escapeHtml(last.target_version || '?')} · Branch ${escapeHtml(last.github_branch || 'main')}</span>`;
|
||||
renderProductionOverview(data);
|
||||
renderDeploymentRuns(data);
|
||||
window.VendooIcons?.render(document.getElementById('operations-updates-panel'));
|
||||
}
|
||||
|
||||
async function loadDeploymentStatus() {
|
||||
@@ -8140,6 +8291,9 @@ function deploymentSettingsPayload() {
|
||||
trigger_mode: document.getElementById('deployment-trigger-mode')?.value || 'webhook',
|
||||
deploy_method: 'GET',
|
||||
deploy_endpoint: '/api/v1/deploy?uuid={uuid}&force={force}',
|
||||
deployment_status_endpoint: document.getElementById('deployment-status-endpoint')?.value || '/api/v1/deployments/{deployment_id}',
|
||||
deployment_timeout_seconds: Number(document.getElementById('deployment-timeout-seconds')?.value || 900),
|
||||
health_poll_seconds: Number(document.getElementById('deployment-poll-seconds')?.value || 15),
|
||||
github_repository: document.getElementById('deployment-github-repository')?.value || 'Masterluke77/vendoo',
|
||||
github_branch: document.getElementById('deployment-github-branch')?.value || 'main',
|
||||
ghcr_image: 'ghcr.io/masterluke77/vendoo',
|
||||
@@ -8238,6 +8392,18 @@ function setupOperationsCenter() {
|
||||
finally { button.disabled = !operationsState.updateFile; button.textContent = 'Update prüfen und vorbereiten'; }
|
||||
});
|
||||
|
||||
document.getElementById('deployment-production-check')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget; const resultBox = document.getElementById('deployment-production-check-result');
|
||||
button.disabled = true; if (resultBox) resultBox.textContent = 'Produktionsgrenzen werden geprüft …';
|
||||
try {
|
||||
const result = await api('POST', '/api/admin/deployment/production-check');
|
||||
const details = (result.results || []).filter(item => item.status !== 'pass').map(item => `${item.title || item.name || item.id}: ${item.message || item.status}`).slice(0, 4).join(' · ');
|
||||
if (resultBox) resultBox.innerHTML = `<strong>${result.overall_status === 'passed' ? 'Produktionscheck bestanden' : result.overall_status === 'warning' ? 'Produktionscheck mit Hinweisen' : 'Produktionscheck fehlgeschlagen'}</strong><span>${Number(result.passed_count || 0)} bestanden · ${Number(result.warning_count || 0)} Hinweise · ${Number(result.failed_count || 0)} Fehler${details ? ` · ${escapeHtml(details)}` : ''}</span>`;
|
||||
await loadDeploymentStatus();
|
||||
} catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Produktionscheck konnte nicht ausgeführt werden</strong><span>${escapeHtml(error.message)}</span>`; }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
document.getElementById('deployment-save')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget; button.disabled = true;
|
||||
try { operationsState.deployment = await api('PUT', '/api/admin/deployment', deploymentSettingsPayload()); renderDeploymentStatus(); toast('Coolify- und GitHub-Konfiguration gespeichert.'); }
|
||||
@@ -8252,7 +8418,12 @@ function setupOperationsCenter() {
|
||||
});
|
||||
document.getElementById('deployment-update-check')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget; const resultBox = document.getElementById('deployment-action-result'); button.disabled = true;
|
||||
try { const result = await api('POST', '/api/admin/deployment/updates/check'); if (resultBox) resultBox.innerHTML = `<strong>${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : result.configured ? 'Vendoo ist aktuell' : 'Keine Manifest-URL konfiguriert'}</strong><span>Installiert: ${escapeHtml(result.current_version || VENDOO_UI_BUILD)}${result.message ? ` · ${escapeHtml(result.message)}` : ''}</span>`; }
|
||||
try {
|
||||
const result = await api('POST', '/api/admin/deployment/updates/check');
|
||||
const target = document.getElementById('deployment-target-version');
|
||||
if (target && result.latest_version) target.value = result.latest_version;
|
||||
if (resultBox) resultBox.innerHTML = `<strong>${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : result.configured ? 'Vendoo ist aktuell' : 'Keine Manifest-URL konfiguriert'}</strong><span>Installiert: ${escapeHtml(result.current_version || VENDOO_UI_BUILD)}${result.latest_version ? ` · Freigegeben: ${escapeHtml(result.latest_version)}` : ''}${result.message ? ` · ${escapeHtml(result.message)}` : ''}</span>`;
|
||||
}
|
||||
catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Updateprüfung fehlgeschlagen</strong><span>${escapeHtml(error.message)}</span>`; }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
@@ -8261,9 +8432,11 @@ function setupOperationsCenter() {
|
||||
if (confirmation !== 'DEPLOY') return;
|
||||
const button = event.currentTarget; const resultBox = document.getElementById('deployment-action-result'); button.disabled = true; button.textContent = 'Deployment wird angefordert …';
|
||||
try {
|
||||
const result = await api('POST', '/api/admin/deployment/trigger', { confirmation, force: false, reason: 'update-center' });
|
||||
if (resultBox) resultBox.innerHTML = `<strong>Coolify-Deployment angenommen</strong><span>${escapeHtml(result.message || '')}${result.backup_file ? ` · Backup: ${escapeHtml(result.backup_file)}` : ''}</span>`;
|
||||
toast('Coolify-Deployment wurde angefordert.');
|
||||
const targetVersion = document.getElementById('deployment-target-version')?.value || operationsState.deployment?.current_version || VENDOO_UI_BUILD;
|
||||
const idempotencyKey = globalThis.crypto?.randomUUID?.() || `vendoo-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const result = await api('POST', '/api/admin/deployment/trigger', { confirmation, force: false, reason: 'update-center', target_version: targetVersion, idempotency_key: idempotencyKey });
|
||||
if (resultBox) resultBox.innerHTML = `<strong>${result.reused ? 'Vorhandener Deployment-Auftrag erkannt' : 'Coolify-Deployment angenommen'}</strong><span>${escapeHtml(result.message || deploymentStateLabel(result.state))}${result.backup_id ? ` · Backup ${escapeHtml(String(result.backup_id).slice(0, 12))}` : ''}</span>`;
|
||||
toast(result.reused ? 'Der gleiche Deployment-Auftrag war bereits vorhanden.' : 'Coolify-Deployment wurde angefordert.');
|
||||
await loadDeploymentStatus();
|
||||
} catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Deployment fehlgeschlagen</strong><span>${escapeHtml(error.message)}</span>`; toast(error.message); }
|
||||
finally { button.disabled = false; button.textContent = 'Coolify-Deployment starten'; }
|
||||
@@ -8291,7 +8464,7 @@ function setupAdmin() {
|
||||
document.getElementById('invite-user-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('invite-email').value = '';
|
||||
document.getElementById('invite-name').value = '';
|
||||
document.getElementById('invite-role').value = 'editor';
|
||||
document.getElementById('invite-role').value = 'viewer';
|
||||
document.getElementById('invite-result-wrap').classList.add('hidden');
|
||||
document.getElementById('invite-result-password').value = '';
|
||||
const mlRadio = document.querySelector('input[name="invite-method"][value="magic_link"]');
|
||||
@@ -8442,13 +8615,48 @@ function exportAdminAudit() {
|
||||
window.location.href = `/api/admin/audit/export.csv?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function loadAdminInvitations() {
|
||||
const container = document.getElementById('invitations-list');
|
||||
if (!container) return;
|
||||
try {
|
||||
const result = await api('GET', '/api/admin/users/invitations');
|
||||
const invitations = result.invitations || [];
|
||||
if (!invitations.length) { container.innerHTML = '<div class="empty-state">Noch keine Einladungen vorhanden.</div>'; return; }
|
||||
const statusLabel = status => ({ pending: 'Offen', expired: 'Abgelaufen', revoked_or_used: 'Verwendet oder widerrufen', revoked: 'Widerrufen' })[status] || status;
|
||||
container.innerHTML = invitations.map(invitation => {
|
||||
const actionable = ['pending', 'expired'].includes(invitation.status);
|
||||
return `<article class="invitation-admin-row ${invitation.status === 'pending' ? 'is-pending' : ''}">
|
||||
<div><strong>${escapeHtml(invitation.email)}</strong><span>${escapeHtml(ROLE_LABELS[invitation.role] || invitation.role || 'Leser')} · ${escapeHtml(statusLabel(invitation.status))}</span><small>Erstellt ${operationFormatDate(invitation.created_at)} · Ablauf ${operationFormatDate(invitation.expires_at)}</small></div>
|
||||
${actionable ? `<div class="invitation-admin-actions"><button type="button" class="small-btn" data-invitation-resend="${invitation.id}" title="Einladung erneut senden"><span data-vd-icon="mail"></span></button>${invitation.status === 'pending' ? `<button type="button" class="small-btn danger-btn" data-invitation-revoke="${invitation.id}" title="Einladung widerrufen"><span data-vd-icon="ban"></span></button>` : ''}</div>` : ''}
|
||||
</article>`;
|
||||
}).join('');
|
||||
container.querySelectorAll('[data-invitation-resend]').forEach(button => button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await api('POST', `/api/admin/users/invitations/${encodeURIComponent(button.dataset.invitationResend)}/resend`);
|
||||
toast(result.consoleFallback ? 'Neue Einladung erstellt; Link wurde in der Server-Konsole ausgegeben.' : 'Einladung erneut gesendet.');
|
||||
await loadAdminInvitations();
|
||||
} catch (error) { toast(error.message); }
|
||||
finally { button.disabled = false; }
|
||||
}));
|
||||
container.querySelectorAll('[data-invitation-revoke]').forEach(button => button.addEventListener('click', async () => {
|
||||
if (!confirm('Diese Einladung sofort widerrufen? Der Link kann danach nicht mehr verwendet werden.')) return;
|
||||
button.disabled = true;
|
||||
try { await api('DELETE', `/api/admin/users/invitations/${encodeURIComponent(button.dataset.invitationRevoke)}`); toast('Einladung widerrufen.'); await loadAdminInvitations(); }
|
||||
catch (error) { toast(error.message); }
|
||||
finally { button.disabled = false; }
|
||||
}));
|
||||
globalThis.VendooIcons?.render?.(container);
|
||||
} catch (error) { container.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
|
||||
}
|
||||
|
||||
async function loadAdminUsers() {
|
||||
try {
|
||||
const users = await api('GET', '/api/admin/users');
|
||||
adminUsersCache = Array.isArray(users) ? users : [];
|
||||
const container = document.getElementById('users-list');
|
||||
if (!container) return;
|
||||
if (!adminUsersCache.length) { container.innerHTML = '<div class="empty-state">Keine User vorhanden</div>'; return; }
|
||||
if (!adminUsersCache.length) { container.innerHTML = '<div class="empty-state">Keine User vorhanden</div>'; await loadAdminInvitations(); return; }
|
||||
|
||||
container.innerHTML = adminUsersCache.map(u => {
|
||||
const initials = getInitials(u.name, u.email);
|
||||
@@ -8480,6 +8688,7 @@ async function loadAdminUsers() {
|
||||
if (user) confirmDeleteUser(user.id, user.email);
|
||||
}));
|
||||
globalThis.VendooIcons?.render?.(container);
|
||||
await loadAdminInvitations();
|
||||
} catch (err) { toast('Fehler: ' + err.message); }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/* Vendoo Article Overview UX 1.44.0
|
||||
* Aufgabenorientierte Filter, adaptive Kartenansicht und reduzierte Mehrfachaktionen.
|
||||
*/
|
||||
|
||||
#history.ux-article-overview-ready {
|
||||
--ux-article-card-min: 340px;
|
||||
}
|
||||
|
||||
.ux-article-toolbar {
|
||||
align-items: center;
|
||||
background: var(--vd-surface-raised);
|
||||
border: 1px solid var(--vd-line);
|
||||
border-radius: var(--vd-radius-md);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(180px, .7fr) minmax(320px, 1.4fr) auto;
|
||||
margin-bottom: 12px;
|
||||
padding: 14px 15px;
|
||||
}
|
||||
|
||||
.ux-article-summary strong,
|
||||
.ux-article-summary span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ux-article-summary strong {
|
||||
color: var(--vd-ink);
|
||||
font-size: 13px;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
|
||||
.ux-article-summary span {
|
||||
color: var(--vd-ink-muted);
|
||||
font-size: 9px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.ux-status-filters,
|
||||
.ux-article-view-switch {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ux-status-filters {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ux-status-filter,
|
||||
.ux-view-choice {
|
||||
align-items: center;
|
||||
background: var(--vd-surface);
|
||||
border: 1px solid var(--vd-line);
|
||||
border-radius: 999px;
|
||||
color: var(--vd-ink-secondary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 680;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
.ux-status-filter:hover,
|
||||
.ux-view-choice:hover {
|
||||
border-color: var(--vd-line-strong);
|
||||
color: var(--vd-ink);
|
||||
}
|
||||
|
||||
.ux-status-filter.is-active,
|
||||
.ux-view-choice.is-active {
|
||||
background: var(--vd-brand-soft);
|
||||
border-color: color-mix(in srgb, var(--vd-brand) 38%, var(--vd-line));
|
||||
color: var(--vd-brand);
|
||||
}
|
||||
|
||||
.ux-article-view-switch {
|
||||
background: var(--vd-surface-muted);
|
||||
border-radius: 999px;
|
||||
justify-self: end;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.ux-view-choice {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
min-width: 62px;
|
||||
}
|
||||
|
||||
.ux-view-choice.is-active {
|
||||
background: var(--vd-surface-raised);
|
||||
border-color: var(--vd-line);
|
||||
box-shadow: var(--vd-shadow-sm);
|
||||
color: var(--vd-ink);
|
||||
}
|
||||
|
||||
.ux-active-filters {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: -3px 0 13px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.ux-active-filters.is-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-active-filters > strong {
|
||||
color: var(--vd-ink-muted);
|
||||
font-size: 9px;
|
||||
margin-right: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ux-filter-chip {
|
||||
align-items: center;
|
||||
background: var(--vd-surface-muted);
|
||||
border: 1px solid var(--vd-line);
|
||||
border-radius: 999px;
|
||||
color: var(--vd-ink-secondary);
|
||||
display: inline-flex;
|
||||
font-size: 9px;
|
||||
gap: 5px;
|
||||
min-height: 25px;
|
||||
padding: 0 4px 0 9px;
|
||||
}
|
||||
|
||||
.ux-filter-chip-clear {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--vd-ink-muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: 15px;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.ux-filter-chip-clear:hover {
|
||||
background: var(--vd-surface-raised);
|
||||
color: var(--vd-danger);
|
||||
}
|
||||
|
||||
#history .history-batch-bar:not(.has-selection) .history-batch-actions,
|
||||
#history .history-batch-bar:not(.has-selection) .history-clear-selection {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#history .history-batch-bar:not(.has-selection) {
|
||||
min-height: 43px;
|
||||
}
|
||||
|
||||
#history .history-batch-bar.has-selection {
|
||||
position: sticky;
|
||||
top: calc(var(--vd-topbar-height) + 8px);
|
||||
z-index: 25;
|
||||
}
|
||||
|
||||
#history .history-batch-actions,
|
||||
.ux-batch-primary,
|
||||
.ux-batch-secondary {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ux-batch-more {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ux-batch-more > summary {
|
||||
align-items: center;
|
||||
border: 1px solid var(--vd-line);
|
||||
border-radius: 7px;
|
||||
color: var(--vd-ink-secondary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
list-style: none;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.ux-batch-more > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-batch-more > summary::after {
|
||||
content: '⌄';
|
||||
margin-left: 7px;
|
||||
}
|
||||
|
||||
.ux-batch-secondary {
|
||||
background: var(--vd-surface-raised);
|
||||
border: 1px solid var(--vd-line);
|
||||
border-radius: var(--vd-radius-sm);
|
||||
box-shadow: var(--vd-shadow-float);
|
||||
flex-direction: column;
|
||||
min-width: 190px;
|
||||
padding: 7px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 7px);
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.ux-batch-secondary .vd-button {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-table-shell {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-batch-bar,
|
||||
#history[data-article-view="cards"] .history-table-footer {
|
||||
background: var(--vd-surface-raised);
|
||||
border: 1px solid var(--vd-line);
|
||||
border-radius: var(--vd-radius-md);
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-batch-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-table-footer {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-table {
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-table-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--ux-article-card-min), 1fr));
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-row {
|
||||
align-content: start;
|
||||
background: var(--vd-surface-raised);
|
||||
border: 1px solid var(--vd-line);
|
||||
border-radius: var(--vd-radius-md);
|
||||
box-shadow: none;
|
||||
display: grid;
|
||||
gap: 0 12px;
|
||||
grid-template-areas:
|
||||
"select article menu"
|
||||
". platform price"
|
||||
". status published"
|
||||
". location provider";
|
||||
grid-template-columns: 22px minmax(0, 1fr) minmax(118px, .7fr);
|
||||
min-height: 0;
|
||||
padding: 14px;
|
||||
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-row:hover {
|
||||
background: var(--vd-surface-raised);
|
||||
border-color: var(--vd-line-strong);
|
||||
box-shadow: var(--vd-shadow-sm);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-row.selected {
|
||||
background: color-mix(in srgb, var(--vd-brand-soft) 42%, var(--vd-surface-raised));
|
||||
border-color: color-mix(in srgb, var(--vd-brand) 45%, var(--vd-line));
|
||||
box-shadow: 0 0 0 2px var(--vd-focus);
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-cell {
|
||||
border-top: 1px solid var(--vd-line);
|
||||
min-width: 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-cell::before {
|
||||
color: var(--vd-ink-muted);
|
||||
content: attr(data-ux-label);
|
||||
display: block;
|
||||
font-size: 7.5px;
|
||||
font-weight: 760;
|
||||
letter-spacing: .06em;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-cell-select {
|
||||
align-self: start;
|
||||
border: 0;
|
||||
grid-area: select;
|
||||
padding: 3px 0 0;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-cell-article {
|
||||
align-items: center;
|
||||
border: 0;
|
||||
grid-area: article;
|
||||
padding: 0 0 13px;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-cell-article::before,
|
||||
#history[data-article-view="cards"] .history-cell-menu::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-cell-platform { grid-area: platform; }
|
||||
#history[data-article-view="cards"] .history-cell-provider { grid-area: provider; }
|
||||
#history[data-article-view="cards"] .history-cell-price { grid-area: price; }
|
||||
#history[data-article-view="cards"] .history-cell-status { grid-area: status; }
|
||||
#history[data-article-view="cards"] .history-cell-location { grid-area: location; }
|
||||
#history[data-article-view="cards"] .history-cell-published { grid-area: published; }
|
||||
|
||||
#history[data-article-view="cards"] .history-cell-menu {
|
||||
align-self: start;
|
||||
border: 0;
|
||||
grid-area: menu;
|
||||
justify-self: end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-thumb {
|
||||
border-radius: 9px;
|
||||
height: 64px;
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-article-title {
|
||||
font-size: 12px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-article-sku {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-cell-provider,
|
||||
#history[data-article-view="cards"] .history-cell-location,
|
||||
#history[data-article-view="cards"] .history-cell-platform,
|
||||
#history[data-article-view="cards"] .history-cell-price,
|
||||
#history[data-article-view="cards"] .history-cell-status,
|
||||
#history[data-article-view="cards"] .history-cell-published {
|
||||
align-items: flex-start;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-status-select {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-publish-badges {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-row[data-publish-state="none"] .history-cell-published {
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
#history[data-article-view="list"] .history-table-head {
|
||||
background: var(--vd-surface-raised);
|
||||
position: sticky;
|
||||
top: var(--vd-topbar-height);
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
#history[data-article-view="list"] .history-row:focus-visible,
|
||||
#history[data-article-view="cards"] .history-row:focus-visible,
|
||||
.ux-status-filter:focus-visible,
|
||||
.ux-view-choice:focus-visible,
|
||||
.ux-filter-chip-clear:focus-visible,
|
||||
.ux-batch-more > summary:focus-visible {
|
||||
outline: 3px solid var(--vd-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.ux-article-toolbar {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.ux-status-filters {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.ux-article-view-switch {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.ux-article-toolbar {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ux-status-filters,
|
||||
.ux-article-view-switch {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.ux-status-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ux-status-filter,
|
||||
.ux-view-choice {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ux-article-view-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
#history .history-batch-bar.has-selection {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
position: static;
|
||||
}
|
||||
|
||||
#history .history-batch-actions,
|
||||
.ux-batch-primary {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ux-batch-primary .vd-button,
|
||||
.ux-batch-more,
|
||||
.ux-batch-more > summary {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ux-batch-secondary {
|
||||
left: 0;
|
||||
right: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] {
|
||||
--ux-article-card-min: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
#history[data-article-view="cards"] .history-list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-row {
|
||||
grid-template-areas:
|
||||
"select article menu"
|
||||
". platform price"
|
||||
". status status"
|
||||
". published published"
|
||||
". location location"
|
||||
". provider provider";
|
||||
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#history[data-article-view="cards"] .history-thumb {
|
||||
height: 58px;
|
||||
width: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#history[data-article-view="cards"] .history-row {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const VIEW_STORAGE_KEY = 'vendoo_article_overview_view_v1';
|
||||
const STATUS_FILTERS = [
|
||||
{ value: '', label: 'Alle' },
|
||||
{ value: 'active', label: 'Aktiv' },
|
||||
{ value: 'reserved', label: 'Reserviert' },
|
||||
{ value: 'sold', label: 'Verkauft' },
|
||||
];
|
||||
const CELL_LABELS = {
|
||||
platform: 'Plattform',
|
||||
provider: 'AI',
|
||||
price: 'Preis',
|
||||
status: 'Status',
|
||||
location: 'Lagerort',
|
||||
published: 'Veröffentlichung',
|
||||
};
|
||||
|
||||
let historyObserver = null;
|
||||
let metaObserver = null;
|
||||
let refreshQueued = false;
|
||||
|
||||
function historySection() {
|
||||
return document.getElementById('history');
|
||||
}
|
||||
|
||||
function createButton(label, className, attributes = {}) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = className;
|
||||
button.textContent = label;
|
||||
for (const [name, value] of Object.entries(attributes)) button.setAttribute(name, value);
|
||||
return button;
|
||||
}
|
||||
|
||||
function currentView() {
|
||||
const stored = localStorage.getItem(VIEW_STORAGE_KEY);
|
||||
return stored === 'list' || stored === 'cards' ? stored : 'cards';
|
||||
}
|
||||
|
||||
function applyView(mode, { persist = true } = {}) {
|
||||
const section = historySection();
|
||||
if (!section) return;
|
||||
const normalized = mode === 'list' ? 'list' : 'cards';
|
||||
section.dataset.articleView = normalized;
|
||||
section.querySelectorAll('[data-article-view-choice]').forEach(button => {
|
||||
const active = button.dataset.articleViewChoice === normalized;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
if (persist) localStorage.setItem(VIEW_STORAGE_KEY, normalized);
|
||||
}
|
||||
|
||||
function setStatusFilter(value) {
|
||||
const select = document.getElementById('filter-status');
|
||||
if (!select) return;
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
queueRefresh();
|
||||
}
|
||||
|
||||
function createOverviewToolbar() {
|
||||
const section = historySection();
|
||||
const filterbar = section?.querySelector('.history-filterbar');
|
||||
if (!section || !filterbar || section.querySelector('.ux-article-toolbar')) return;
|
||||
|
||||
const toolbar = document.createElement('section');
|
||||
toolbar.className = 'ux-article-toolbar';
|
||||
toolbar.setAttribute('aria-label', 'Artikelansicht und Schnellfilter');
|
||||
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'ux-article-summary';
|
||||
const summaryTitle = document.createElement('strong');
|
||||
summaryTitle.textContent = 'Artikelübersicht';
|
||||
const summaryCount = document.createElement('span');
|
||||
summaryCount.id = 'ux-article-result-count';
|
||||
summaryCount.textContent = 'Artikel werden geladen …';
|
||||
summary.append(summaryTitle, summaryCount);
|
||||
|
||||
const filters = document.createElement('div');
|
||||
filters.className = 'ux-status-filters';
|
||||
filters.setAttribute('role', 'group');
|
||||
filters.setAttribute('aria-label', 'Status schnell filtern');
|
||||
for (const filter of STATUS_FILTERS) {
|
||||
const button = createButton(filter.label, 'ux-status-filter', {
|
||||
'data-article-status': filter.value,
|
||||
'aria-pressed': 'false',
|
||||
});
|
||||
button.addEventListener('click', () => setStatusFilter(filter.value));
|
||||
filters.append(button);
|
||||
}
|
||||
|
||||
const viewSwitch = document.createElement('div');
|
||||
viewSwitch.className = 'ux-article-view-switch';
|
||||
viewSwitch.setAttribute('role', 'group');
|
||||
viewSwitch.setAttribute('aria-label', 'Darstellung wählen');
|
||||
const cards = createButton('Karten', 'ux-view-choice', {
|
||||
'data-article-view-choice': 'cards',
|
||||
'aria-pressed': 'false',
|
||||
});
|
||||
const list = createButton('Liste', 'ux-view-choice', {
|
||||
'data-article-view-choice': 'list',
|
||||
'aria-pressed': 'false',
|
||||
});
|
||||
cards.addEventListener('click', () => applyView('cards'));
|
||||
list.addEventListener('click', () => applyView('list'));
|
||||
viewSwitch.append(cards, list);
|
||||
|
||||
toolbar.append(summary, filters, viewSwitch);
|
||||
|
||||
const activeFilters = document.createElement('div');
|
||||
activeFilters.className = 'ux-active-filters';
|
||||
activeFilters.id = 'ux-active-filters';
|
||||
activeFilters.setAttribute('aria-live', 'polite');
|
||||
|
||||
filterbar.before(toolbar);
|
||||
filterbar.after(activeFilters);
|
||||
}
|
||||
|
||||
function enhanceBatchActions() {
|
||||
const actions = document.querySelector('#history .history-batch-actions');
|
||||
if (!actions || actions.querySelector('.ux-batch-primary')) return;
|
||||
|
||||
const primary = document.createElement('div');
|
||||
primary.className = 'ux-batch-primary';
|
||||
const more = document.createElement('details');
|
||||
more.className = 'ux-batch-more';
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = 'Weitere Aktionen';
|
||||
const secondary = document.createElement('div');
|
||||
secondary.className = 'ux-batch-secondary';
|
||||
more.append(summary, secondary);
|
||||
|
||||
const primaryIds = ['history-publish-selected', 'history-edit-selected', 'history-bulk-status'];
|
||||
const secondaryIds = ['history-price-selected', 'history-duplicate-selected', 'history-print-labels', 'history-delete-selected'];
|
||||
for (const id of primaryIds) {
|
||||
const button = document.getElementById(id);
|
||||
if (button) primary.append(button);
|
||||
}
|
||||
for (const id of secondaryIds) {
|
||||
const button = document.getElementById(id);
|
||||
if (button) secondary.append(button);
|
||||
}
|
||||
actions.append(primary, more);
|
||||
}
|
||||
|
||||
function annotateRows() {
|
||||
const container = document.getElementById('history-list');
|
||||
if (!container) return;
|
||||
container.querySelectorAll('.history-row').forEach(row => {
|
||||
for (const [column, label] of Object.entries(CELL_LABELS)) {
|
||||
row.querySelector(`[data-column="${column}"]`)?.setAttribute('data-ux-label', label);
|
||||
}
|
||||
const title = row.querySelector('.history-article-title')?.textContent?.trim() || 'Artikel';
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('aria-label', `${title} öffnen`);
|
||||
row.dataset.publishState = row.querySelector('.history-publish-empty') ? 'none' : 'available';
|
||||
if (row.dataset.uxKeyboardBound !== 'true') {
|
||||
row.dataset.uxKeyboardBound = 'true';
|
||||
row.addEventListener('keydown', event => {
|
||||
if ((event.key === 'Enter' || event.key === ' ') && event.target === row) {
|
||||
event.preventDefault();
|
||||
row.querySelector('.history-article-title')?.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncStatusButtons() {
|
||||
const value = document.getElementById('filter-status')?.value || '';
|
||||
document.querySelectorAll('[data-article-status]').forEach(button => {
|
||||
const active = button.dataset.articleStatus === value;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
}
|
||||
|
||||
function selectedOptionText(select) {
|
||||
if (!select || !select.value) return '';
|
||||
return select.options[select.selectedIndex]?.textContent?.trim() || select.value;
|
||||
}
|
||||
|
||||
function createFilterChip(label, onClear) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'ux-filter-chip';
|
||||
const text = document.createElement('span');
|
||||
text.textContent = label;
|
||||
const clear = createButton('×', 'ux-filter-chip-clear', { 'aria-label': `${label} entfernen` });
|
||||
clear.addEventListener('click', onClear);
|
||||
chip.append(text, clear);
|
||||
return chip;
|
||||
}
|
||||
|
||||
function clearControl(control, value = '') {
|
||||
if (!control) return;
|
||||
control.value = value;
|
||||
control.dispatchEvent(new Event(control.tagName === 'INPUT' ? 'input' : 'change', { bubbles: true }));
|
||||
queueRefresh();
|
||||
}
|
||||
|
||||
function updateActiveFilters() {
|
||||
const container = document.getElementById('ux-active-filters');
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
const query = document.getElementById('search-input');
|
||||
const platform = document.getElementById('filter-platform');
|
||||
const status = document.getElementById('filter-status');
|
||||
const period = document.getElementById('history-period');
|
||||
const dateFrom = document.getElementById('filter-date-from');
|
||||
const dateTo = document.getElementById('filter-date-to');
|
||||
const chips = [];
|
||||
|
||||
if (query?.value.trim()) chips.push(createFilterChip(`Suche: ${query.value.trim()}`, () => clearControl(query)));
|
||||
if (platform?.value) chips.push(createFilterChip(`Plattform: ${selectedOptionText(platform)}`, () => clearControl(platform)));
|
||||
if (status?.value) chips.push(createFilterChip(`Status: ${selectedOptionText(status)}`, () => clearControl(status)));
|
||||
if (period?.value && period.value !== '90' && period.value !== 'custom') {
|
||||
chips.push(createFilterChip(`Zeitraum: ${selectedOptionText(period)}`, () => clearControl(period, '90')));
|
||||
}
|
||||
if (dateFrom?.value) chips.push(createFilterChip(`Von: ${dateFrom.value}`, () => clearControl(dateFrom)));
|
||||
if (dateTo?.value) chips.push(createFilterChip(`Bis: ${dateTo.value}`, () => clearControl(dateTo)));
|
||||
|
||||
if (!chips.length) {
|
||||
container.classList.add('is-empty');
|
||||
return;
|
||||
}
|
||||
container.classList.remove('is-empty');
|
||||
const label = document.createElement('strong');
|
||||
label.textContent = 'Aktive Filter';
|
||||
container.append(label, ...chips);
|
||||
}
|
||||
|
||||
function updateResultCount() {
|
||||
const target = document.getElementById('ux-article-result-count');
|
||||
if (!target) return;
|
||||
const info = document.getElementById('history-total-info')?.textContent?.trim() || '';
|
||||
const match = info.match(/von\s+(\d+)\s+Eintr/i);
|
||||
const visible = document.querySelectorAll('#history-list .history-row').length;
|
||||
if (match) target.textContent = `${match[1]} Artikel gefunden`;
|
||||
else target.textContent = visible === 1 ? '1 Artikel gefunden' : `${visible} Artikel gefunden`;
|
||||
}
|
||||
|
||||
function syncBatchState() {
|
||||
const section = historySection();
|
||||
const countText = document.getElementById('history-selected-count')?.textContent || '0';
|
||||
const count = Number.parseInt(countText, 10) || 0;
|
||||
section?.toggleAttribute('data-has-selection', count > 0);
|
||||
const more = section?.querySelector('.ux-batch-more');
|
||||
if (!count && more) more.open = false;
|
||||
}
|
||||
|
||||
function refreshPresentation() {
|
||||
refreshQueued = false;
|
||||
annotateRows();
|
||||
syncStatusButtons();
|
||||
updateActiveFilters();
|
||||
updateResultCount();
|
||||
syncBatchState();
|
||||
}
|
||||
|
||||
function queueRefresh() {
|
||||
if (refreshQueued) return;
|
||||
refreshQueued = true;
|
||||
window.requestAnimationFrame(refreshPresentation);
|
||||
}
|
||||
|
||||
function bindFilterState() {
|
||||
const ids = ['search-input', 'filter-platform', 'filter-status', 'history-period', 'filter-date-from', 'filter-date-to'];
|
||||
for (const id of ids) {
|
||||
const control = document.getElementById(id);
|
||||
if (!control || control.dataset.uxArticleBound === 'true') continue;
|
||||
control.dataset.uxArticleBound = 'true';
|
||||
control.addEventListener('input', queueRefresh);
|
||||
control.addEventListener('change', queueRefresh);
|
||||
}
|
||||
document.getElementById('history-reset-filters')?.addEventListener('click', () => window.setTimeout(queueRefresh, 0));
|
||||
const search = document.getElementById('search-input');
|
||||
if (search) search.placeholder = 'Titel, SKU, Marke oder Beschreibung durchsuchen';
|
||||
}
|
||||
|
||||
function observeArticleOverview() {
|
||||
const list = document.getElementById('history-list');
|
||||
const shell = document.querySelector('#history .history-table-shell');
|
||||
if (list) {
|
||||
historyObserver = new MutationObserver(queueRefresh);
|
||||
historyObserver.observe(list, { childList: true, subtree: true });
|
||||
}
|
||||
if (shell) {
|
||||
metaObserver = new MutationObserver(queueRefresh);
|
||||
metaObserver.observe(shell, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
window.addEventListener('beforeunload', () => {
|
||||
historyObserver?.disconnect();
|
||||
metaObserver?.disconnect();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
function initializeArticleOverview() {
|
||||
const section = historySection();
|
||||
if (!section || section.classList.contains('ux-article-overview-ready')) return;
|
||||
createOverviewToolbar();
|
||||
enhanceBatchActions();
|
||||
bindFilterState();
|
||||
applyView(currentView(), { persist: false });
|
||||
observeArticleOverview();
|
||||
refreshPresentation();
|
||||
section.classList.add('ux-article-overview-ready');
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeArticleOverview, { once: true });
|
||||
} else {
|
||||
initializeArticleOverview();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
.ux-command-platform-chrome{display:flex;align-items:center;gap:10px;min-height:42px;padding:7px 18px;border-bottom:1px solid var(--vd-color-border,rgba(127,127,127,.2));background:var(--vd-color-surface,#fff);position:relative;z-index:20}.ux-command-breadcrumb{display:flex;align-items:center;gap:8px;min-width:0;flex:1}.ux-command-crumb,.ux-command-favorite-toggle,.ux-command-drawer-toggle,.ux-command-drawer-item{font:inherit;color:inherit;border:1px solid var(--vd-color-border,rgba(127,127,127,.25));background:var(--vd-color-surface-raised,var(--vd-color-surface,#fff));border-radius:var(--vd-radius-sm,8px)}.ux-command-crumb{border:0;background:transparent;padding:4px 0;font-weight:650}.ux-command-crumb-separator{opacity:.5}.ux-command-crumb-current{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--vd-color-text-muted,#666);font-size:.9rem}.ux-command-favorite-toggle{width:34px;height:30px;padding:0;font-size:1rem}.ux-command-drawer-toggle{padding:6px 10px;font-size:.84rem}.ux-command-drawer{position:absolute;right:18px;top:48px;width:min(360px,calc(100vw - 28px));max-height:min(520px,70vh);overflow:auto;padding:12px;border:1px solid var(--vd-color-border,rgba(127,127,127,.25));border-radius:var(--vd-radius-lg,14px);background:var(--vd-color-surface-raised,var(--vd-color-surface,#fff));box-shadow:var(--vd-shadow-lg,0 18px 48px rgba(0,0,0,.18));display:grid;gap:14px}.ux-command-drawer.hidden{display:none}.ux-command-drawer-section{display:grid;gap:7px}.ux-command-drawer-section>strong{font-size:.75rem;letter-spacing:.05em;text-transform:uppercase;color:var(--vd-color-text-muted,#666)}.ux-command-drawer-item{text-align:left;padding:9px 10px;cursor:pointer}.ux-command-drawer-empty{padding:8px 2px;color:var(--vd-color-text-muted,#777);font-size:.85rem}.ux-command-crumb:hover,.ux-command-favorite-toggle:hover,.ux-command-drawer-toggle:hover,.ux-command-drawer-item:hover{background:var(--vd-color-surface-hover,rgba(127,127,127,.09))}.ux-command-crumb:focus-visible,.ux-command-favorite-toggle:focus-visible,.ux-command-drawer-toggle:focus-visible,.ux-command-drawer-item:focus-visible{outline:2px solid var(--vd-color-focus,#4f7cff);outline-offset:2px}@media(max-width:780px){.ux-command-platform-chrome{padding-inline:12px}.ux-command-drawer-toggle{font-size:0;width:34px;height:30px;padding:0}.ux-command-drawer-toggle::before{content:'★';font-size:1rem}.ux-command-crumb-current{font-size:.82rem}}@media(max-width:520px){.ux-command-platform-chrome{gap:6px}.ux-command-crumb-separator{display:none}.ux-command-crumb-current{max-width:48vw}.ux-command-drawer{right:8px;top:46px;width:calc(100vw - 16px)}}@media(prefers-reduced-motion:reduce){.ux-command-platform-chrome *{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
|
||||
@@ -0,0 +1,453 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
if (document.documentElement.dataset.uxCommandPlatformReady === '1') return;
|
||||
document.documentElement.dataset.uxCommandPlatformReady = '1';
|
||||
|
||||
const FAVORITES_KEY = 'vendoo_command_favorites_v1';
|
||||
const RECENTS_KEY = 'vendoo_navigation_recents_v1';
|
||||
const MAX_RECENTS = 12;
|
||||
const registry = new Map();
|
||||
const listeners = new Set();
|
||||
|
||||
const VIEW_META = {
|
||||
dashboard: { title: 'Übersicht', group: 'Start', icon: '⌂', keywords: ['dashboard', 'start'], aliases: ['home'] },
|
||||
today: { title: 'Aufgaben', group: 'Start', icon: '✓', keywords: ['heute', 'tagesarbeitsplatz'], aliases: ['today'] },
|
||||
generator: { title: 'Neuer Artikel', group: 'Erstellen', icon: '+', keywords: ['generator', 'listing erstellen'], aliases: ['artikel erstellen'] },
|
||||
history: { title: 'Artikel', group: 'Verkaufen', icon: '□', keywords: ['listings', 'artikelübersicht'], aliases: ['historie'] },
|
||||
'quality-center': { title: 'Qualität', group: 'Optimieren', icon: '◇', keywords: ['qualitätscenter', 'prüfen'], aliases: ['quality'] },
|
||||
publish: { title: 'Veröffentlichen', group: 'Verkaufen', icon: '↗', keywords: ['publish', 'marktplätze'], aliases: ['publishing'] },
|
||||
inventory: { title: 'Lager', group: 'Bestand', icon: '▣', keywords: ['bestand', 'lagerorte'], aliases: ['inventory'] },
|
||||
templates: { title: 'Vorlagen', group: 'Inhalte', icon: '▤', keywords: ['templates'], aliases: ['template'] },
|
||||
'media-library': { title: 'Medien', group: 'Inhalte', icon: '▧', keywords: ['bilder', 'galerie'], aliases: ['medienbibliothek'] },
|
||||
'image-factory': { title: 'Bildwerkstatt', group: 'AI & Bilder', icon: '✦', keywords: ['bildproduktion'], aliases: ['image factory'] },
|
||||
'flux-studio': { title: 'FLUX Studio', group: 'AI & Bilder', icon: '✧', keywords: ['comfyui', 'ai bilder'], aliases: ['flux'] },
|
||||
extensions: { title: 'Erweiterungen', group: 'System', icon: '⌘', keywords: ['extensions', 'browser'], aliases: ['browser erweiterung'] },
|
||||
settings: { title: 'Einstellungen', group: 'System', icon: '⚙', keywords: ['verbindungen', 'apis'], aliases: ['settings'] },
|
||||
admin: { title: 'Verwaltung', group: 'System', icon: '◈', keywords: ['benutzer', 'admin'], aliases: ['administration'] },
|
||||
fees: { title: 'Gebühren', group: 'Verkaufen', icon: '€', keywords: ['kosten'], aliases: ['fees'] },
|
||||
trash: { title: 'Papierkorb', group: 'System', icon: '⌫', keywords: ['gelöscht'], aliases: ['trash'] },
|
||||
};
|
||||
|
||||
const readList = key => {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key) || '[]');
|
||||
return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeList = (key, value) => {
|
||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
|
||||
};
|
||||
|
||||
let favorites = readList(FAVORITES_KEY);
|
||||
let recents = readList(RECENTS_KEY);
|
||||
|
||||
const emit = (type, detail = {}) => {
|
||||
window.dispatchEvent(new CustomEvent(`vendoo:${type}`, { detail }));
|
||||
listeners.forEach(listener => {
|
||||
try { listener({ type, detail }); } catch {}
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeSearchText = value => String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLocaleLowerCase('de-DE')
|
||||
.trim();
|
||||
|
||||
const normalizeStringList = value => Array.isArray(value)
|
||||
? [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))]
|
||||
: [];
|
||||
|
||||
function normalizeCommand(input) {
|
||||
if (!input || typeof input !== 'object') throw new TypeError('Command muss ein Objekt sein.');
|
||||
const id = String(input.id || '').trim();
|
||||
const title = String(input.title || '').trim();
|
||||
if (!id || !title) throw new TypeError('Command benötigt id und title.');
|
||||
return Object.freeze({
|
||||
id,
|
||||
title,
|
||||
description: String(input.description || ''),
|
||||
group: String(input.group || 'Allgemein'),
|
||||
icon: String(input.icon || '•'),
|
||||
keywords: normalizeStringList(input.keywords),
|
||||
aliases: normalizeStringList(input.aliases),
|
||||
shortcut: String(input.shortcut || ''),
|
||||
permission: input.permission ?? null,
|
||||
visible: input.visible ?? null,
|
||||
enabled: input.enabled ?? null,
|
||||
disabledReason: String(input.disabledReason || ''),
|
||||
kind: String(input.kind || 'navigation'),
|
||||
view: input.view ? String(input.view) : null,
|
||||
execute: typeof input.execute === 'function' ? input.execute : null,
|
||||
});
|
||||
}
|
||||
|
||||
function register(command) {
|
||||
const normalized = normalizeCommand(command);
|
||||
registry.set(normalized.id, normalized);
|
||||
emit('commands-changed', { commandId: normalized.id });
|
||||
return () => unregister(normalized.id);
|
||||
}
|
||||
|
||||
function unregister(id) {
|
||||
const commandId = String(id);
|
||||
const removed = registry.delete(commandId);
|
||||
if (removed) {
|
||||
favorites = favorites.filter(item => item !== commandId);
|
||||
writeList(FAVORITES_KEY, favorites);
|
||||
emit('commands-changed', { commandId });
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function getActiveView() {
|
||||
return document.querySelector('.sidebar-nav .nav-item.active[data-tab]')?.dataset.tab
|
||||
|| document.querySelector('.tab-content.active')?.id
|
||||
|| 'dashboard';
|
||||
}
|
||||
|
||||
function getNavItem(view) {
|
||||
return document.querySelector(`.sidebar-nav .nav-item[data-tab="${CSS.escape(view)}"]`);
|
||||
}
|
||||
|
||||
function canOpen(view) {
|
||||
const item = getNavItem(view);
|
||||
return Boolean(item && !item.classList.contains('hidden') && item.getAttribute('aria-hidden') !== 'true');
|
||||
}
|
||||
|
||||
function openView(view) {
|
||||
const item = getNavItem(view);
|
||||
if (!item || !canOpen(view)) return false;
|
||||
item.closest('details.ux-nav-section')?.setAttribute('open', '');
|
||||
item.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
function evaluateRule(rule, context, fallback) {
|
||||
if (typeof rule === 'function') {
|
||||
try { return Boolean(rule(context)); } catch { return false; }
|
||||
}
|
||||
if (typeof rule === 'boolean') return rule;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function evaluatePermission(permission, context) {
|
||||
if (permission === null || permission === undefined || permission === '') return true;
|
||||
if (typeof permission === 'function') {
|
||||
try { return Boolean(permission(context)); } catch { return false; }
|
||||
}
|
||||
if (typeof permission === 'boolean') return permission;
|
||||
const resolver = context?.canPermission || window.VendooPermissions?.can;
|
||||
if (typeof resolver !== 'function') return false;
|
||||
try {
|
||||
if (Array.isArray(permission)) return permission.every(value => resolver.call(window.VendooPermissions, value, context));
|
||||
return Boolean(resolver.call(window.VendooPermissions, permission, context));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCommand(commandOrId, context = {}) {
|
||||
const command = typeof commandOrId === 'string' ? registry.get(commandOrId) : commandOrId;
|
||||
if (!command) return null;
|
||||
const viewVisible = !command.view || canOpen(command.view);
|
||||
const visible = viewVisible && evaluateRule(command.visible, context, true) && evaluatePermission(command.permission, context);
|
||||
const enabled = visible && evaluateRule(command.enabled, context, true);
|
||||
const disabledReason = enabled
|
||||
? ''
|
||||
: (command.disabledReason || (!viewVisible ? 'Dieser Bereich ist derzeit nicht verfügbar.' : 'Dieser Befehl ist derzeit nicht verfügbar.'));
|
||||
return Object.freeze({ visible, enabled, disabledReason });
|
||||
}
|
||||
|
||||
function subsequenceScore(value, needle) {
|
||||
if (!value || !needle) return 0;
|
||||
let cursor = 0;
|
||||
let gaps = 0;
|
||||
for (const character of needle) {
|
||||
const next = value.indexOf(character, cursor);
|
||||
if (next < 0) return 0;
|
||||
gaps += next - cursor;
|
||||
cursor = next + 1;
|
||||
}
|
||||
return Math.max(1, 90 - gaps);
|
||||
}
|
||||
|
||||
function scoreField(value, needle, weights) {
|
||||
const normalized = normalizeSearchText(value);
|
||||
if (!normalized || !needle) return 0;
|
||||
if (normalized === needle) return weights.exact;
|
||||
if (normalized.startsWith(needle)) return weights.prefix;
|
||||
if (normalized.split(/\s+/).some(token => token.startsWith(needle))) return weights.token;
|
||||
if (normalized.includes(needle)) return weights.includes;
|
||||
return Math.round(subsequenceScore(normalized, needle) * weights.fuzzy);
|
||||
}
|
||||
|
||||
function scoreCommand(command, query) {
|
||||
const needle = normalizeSearchText(query);
|
||||
if (!needle) return 0;
|
||||
const scores = [
|
||||
scoreField(command.title, needle, { exact: 1200, prefix: 1000, token: 850, includes: 720, fuzzy: 1.8 }),
|
||||
...command.aliases.map(value => scoreField(value, needle, { exact: 1100, prefix: 920, token: 800, includes: 680, fuzzy: 1.6 })),
|
||||
...command.keywords.map(value => scoreField(value, needle, { exact: 820, prefix: 700, token: 620, includes: 520, fuzzy: 1.25 })),
|
||||
scoreField(command.description, needle, { exact: 500, prefix: 430, token: 360, includes: 300, fuzzy: .8 }),
|
||||
scoreField(command.group, needle, { exact: 360, prefix: 320, token: 280, includes: 230, fuzzy: .65 }),
|
||||
];
|
||||
return Math.max(0, ...scores);
|
||||
}
|
||||
|
||||
function list({ query = '', group = null, context = {}, includeDisabled = true, includeHidden = false } = {}) {
|
||||
const needle = normalizeSearchText(query);
|
||||
return [...registry.values()]
|
||||
.map(command => {
|
||||
const status = resolveCommand(command, context);
|
||||
const favoriteIndex = favorites.indexOf(command.id);
|
||||
const recentView = command.view ? recents.indexOf(command.view) : -1;
|
||||
const score = scoreCommand(command, needle)
|
||||
+ (favoriteIndex >= 0 ? Math.max(40, 120 - favoriteIndex * 8) : 0)
|
||||
+ (recentView >= 0 ? Math.max(20, 80 - recentView * 5) : 0);
|
||||
return Object.freeze({
|
||||
...command,
|
||||
...status,
|
||||
favorite: favoriteIndex >= 0,
|
||||
favoriteIndex,
|
||||
recent: recentView >= 0,
|
||||
recentIndex: recentView,
|
||||
score,
|
||||
});
|
||||
})
|
||||
.filter(command => (!group || command.group === group)
|
||||
&& (includeHidden || command.visible)
|
||||
&& (includeDisabled || command.enabled)
|
||||
&& (!needle || command.score > 0))
|
||||
.sort((left, right) => {
|
||||
if (needle && right.score !== left.score) return right.score - left.score;
|
||||
if (!needle && left.favorite !== right.favorite) return left.favorite ? -1 : 1;
|
||||
if (!needle && left.favorite && right.favorite && left.favoriteIndex !== right.favoriteIndex) return left.favoriteIndex - right.favoriteIndex;
|
||||
if (!needle && left.recent !== right.recent) return left.recent ? -1 : 1;
|
||||
if (!needle && left.recent && right.recent && left.recentIndex !== right.recentIndex) return left.recentIndex - right.recentIndex;
|
||||
return left.title.localeCompare(right.title, 'de-DE', { sensitivity: 'base' });
|
||||
});
|
||||
}
|
||||
|
||||
function recordRecent(view) {
|
||||
if (!view || !VIEW_META[view]) return;
|
||||
recents = [view, ...recents.filter(item => item !== view)].slice(0, MAX_RECENTS);
|
||||
writeList(RECENTS_KEY, recents);
|
||||
renderNavigationChrome();
|
||||
emit('navigation-changed', { view, recents: [...recents] });
|
||||
}
|
||||
|
||||
function toggleFavorite(commandId) {
|
||||
const id = String(commandId);
|
||||
if (!registry.has(id)) return false;
|
||||
favorites = favorites.includes(id) ? favorites.filter(item => item !== id) : [id, ...favorites];
|
||||
writeList(FAVORITES_KEY, favorites);
|
||||
renderNavigationChrome();
|
||||
emit('favorites-changed', { commandId: id, favorites: [...favorites] });
|
||||
return favorites.includes(id);
|
||||
}
|
||||
|
||||
function execute(id, context = {}) {
|
||||
const command = registry.get(String(id));
|
||||
if (!command) return false;
|
||||
const status = resolveCommand(command, context);
|
||||
if (!status?.visible || !status.enabled) {
|
||||
emit('command-blocked', { commandId: command.id, reason: status?.disabledReason || 'Befehl nicht verfügbar.' });
|
||||
return false;
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = command.execute ? command.execute(context) : (command.view ? openView(command.view) : false);
|
||||
} catch (error) {
|
||||
emit('command-failed', { commandId: command.id, message: error?.message || String(error) });
|
||||
return false;
|
||||
}
|
||||
if (result !== false && command.view) recordRecent(command.view);
|
||||
emit('command-executed', { commandId: command.id, view: command.view });
|
||||
return result !== false;
|
||||
}
|
||||
|
||||
function createButton(className, label, title) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = className;
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
return button;
|
||||
}
|
||||
|
||||
function ensureChrome() {
|
||||
const topbar = document.getElementById('topbar');
|
||||
if (!topbar || document.getElementById('ux-command-platform-chrome')) return;
|
||||
const chrome = document.createElement('div');
|
||||
chrome.id = 'ux-command-platform-chrome';
|
||||
chrome.className = 'ux-command-platform-chrome';
|
||||
|
||||
const breadcrumb = document.createElement('nav');
|
||||
breadcrumb.id = 'ux-command-breadcrumb';
|
||||
breadcrumb.className = 'ux-command-breadcrumb';
|
||||
breadcrumb.setAttribute('aria-label', 'Brotkrümelnavigation');
|
||||
|
||||
const favoriteButton = createButton('ux-command-favorite-toggle', '☆', 'Aktuellen Bereich zu Favoriten hinzufügen');
|
||||
favoriteButton.id = 'ux-command-favorite-toggle';
|
||||
favoriteButton.setAttribute('aria-pressed', 'false');
|
||||
|
||||
const drawerButton = createButton('ux-command-drawer-toggle', 'Favoriten', 'Favoriten und zuletzt geöffnete Bereiche anzeigen');
|
||||
drawerButton.id = 'ux-command-drawer-toggle';
|
||||
drawerButton.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const drawer = document.createElement('section');
|
||||
drawer.id = 'ux-command-drawer';
|
||||
drawer.className = 'ux-command-drawer hidden';
|
||||
drawer.setAttribute('aria-label', 'Favoriten und Verlauf');
|
||||
|
||||
favoriteButton.addEventListener('click', () => toggleFavorite(`view:${getActiveView()}`));
|
||||
drawerButton.addEventListener('click', () => {
|
||||
const open = drawer.classList.toggle('hidden') === false;
|
||||
drawerButton.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
if (open) drawer.querySelector('button')?.focus({ preventScroll: true });
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !drawer.classList.contains('hidden')) {
|
||||
drawer.classList.add('hidden');
|
||||
drawerButton.setAttribute('aria-expanded', 'false');
|
||||
drawerButton.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
chrome.append(breadcrumb, favoriteButton, drawerButton, drawer);
|
||||
topbar.insertAdjacentElement('afterend', chrome);
|
||||
}
|
||||
|
||||
function renderNavigationChrome() {
|
||||
ensureChrome();
|
||||
const view = getActiveView();
|
||||
const meta = VIEW_META[view] || { title: view, group: 'Vendoo' };
|
||||
const breadcrumb = document.getElementById('ux-command-breadcrumb');
|
||||
const favoriteButton = document.getElementById('ux-command-favorite-toggle');
|
||||
const drawer = document.getElementById('ux-command-drawer');
|
||||
if (!breadcrumb || !favoriteButton || !drawer) return;
|
||||
|
||||
breadcrumb.replaceChildren();
|
||||
const home = createButton('ux-command-crumb', 'Vendoo', 'Zur Übersicht');
|
||||
home.addEventListener('click', () => execute('view:dashboard'));
|
||||
const separator = document.createElement('span');
|
||||
separator.className = 'ux-command-crumb-separator';
|
||||
separator.textContent = '›';
|
||||
separator.setAttribute('aria-hidden', 'true');
|
||||
const current = document.createElement('span');
|
||||
current.className = 'ux-command-crumb-current';
|
||||
current.textContent = `${meta.group} · ${meta.title}`;
|
||||
breadcrumb.append(home, separator, current);
|
||||
|
||||
const commandId = `view:${view}`;
|
||||
const favorite = favorites.includes(commandId);
|
||||
favoriteButton.textContent = favorite ? '★' : '☆';
|
||||
favoriteButton.setAttribute('aria-pressed', favorite ? 'true' : 'false');
|
||||
favoriteButton.title = favorite ? 'Aus Favoriten entfernen' : 'Aktuellen Bereich zu Favoriten hinzufügen';
|
||||
|
||||
drawer.replaceChildren();
|
||||
const appendSection = (title, ids) => {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'ux-command-drawer-section';
|
||||
const heading = document.createElement('strong');
|
||||
heading.textContent = title;
|
||||
section.append(heading);
|
||||
const valid = ids
|
||||
.map(id => id.startsWith('view:') ? id : `view:${id}`)
|
||||
.map(id => list().find(command => command.id === id))
|
||||
.filter(Boolean);
|
||||
if (!valid.length) {
|
||||
const empty = document.createElement('span');
|
||||
empty.className = 'ux-command-drawer-empty';
|
||||
empty.textContent = title === 'Favoriten' ? 'Noch keine Favoriten angeheftet.' : 'Noch keine Bereiche geöffnet.';
|
||||
section.append(empty);
|
||||
} else {
|
||||
valid.forEach(command => {
|
||||
const button = createButton('ux-command-drawer-item', `${command.icon} ${command.title}`, command.description || command.title);
|
||||
button.addEventListener('click', () => {
|
||||
execute(command.id, { source: 'command-drawer' });
|
||||
drawer.classList.add('hidden');
|
||||
document.getElementById('ux-command-drawer-toggle')?.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
section.append(button);
|
||||
});
|
||||
}
|
||||
drawer.append(section);
|
||||
};
|
||||
appendSection('Favoriten', favorites);
|
||||
appendSection('Zuletzt geöffnet', recents);
|
||||
}
|
||||
|
||||
function registerCoreNavigation() {
|
||||
Object.entries(VIEW_META).forEach(([view, meta]) => {
|
||||
register({
|
||||
id: `view:${view}`,
|
||||
title: meta.title,
|
||||
description: `${meta.group} öffnen`,
|
||||
group: meta.group,
|
||||
icon: meta.icon,
|
||||
keywords: meta.keywords,
|
||||
aliases: meta.aliases,
|
||||
kind: 'navigation',
|
||||
view,
|
||||
execute: () => openView(view),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function observeNavigation() {
|
||||
const nav = document.querySelector('.sidebar-nav');
|
||||
if (!nav) return;
|
||||
let current = getActiveView();
|
||||
recordRecent(current);
|
||||
const observer = new MutationObserver(() => {
|
||||
const next = getActiveView();
|
||||
if (!next || next === current) return;
|
||||
current = next;
|
||||
recordRecent(next);
|
||||
emit('commands-changed', { reason: 'navigation-visibility' });
|
||||
});
|
||||
nav.querySelectorAll('.nav-item[data-tab]').forEach(item => observer.observe(item, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'aria-hidden', 'disabled'],
|
||||
}));
|
||||
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
|
||||
}
|
||||
|
||||
window.VendooCommands = Object.freeze({
|
||||
register,
|
||||
unregister,
|
||||
list,
|
||||
get: id => registry.get(String(id)) || null,
|
||||
resolve: (id, context = {}) => resolveCommand(String(id), context),
|
||||
canExecute: (id, context = {}) => Boolean(resolveCommand(String(id), context)?.enabled),
|
||||
execute,
|
||||
toggleFavorite,
|
||||
isFavorite: id => favorites.includes(String(id)),
|
||||
get favorites() { return [...favorites]; },
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== 'function') throw new TypeError('Listener muss eine Funktion sein.');
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
});
|
||||
|
||||
window.VendooNavigation = Object.freeze({
|
||||
get currentView() { return getActiveView(); },
|
||||
get recents() { return [...recents]; },
|
||||
open: openView,
|
||||
back: () => history.back(),
|
||||
forward: () => history.forward(),
|
||||
home: () => execute('view:dashboard'),
|
||||
});
|
||||
|
||||
registerCoreNavigation();
|
||||
ensureChrome();
|
||||
renderNavigationChrome();
|
||||
observeNavigation();
|
||||
})();
|
||||
@@ -70,7 +70,175 @@ function restoreStored() {
|
||||
return applyState(readStoredState(), { persist: false });
|
||||
}
|
||||
|
||||
function loadUxFoundation() {
|
||||
if (!document.querySelector('link[data-vendoo-ux-foundation]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/ux-foundation.css?v=1.44.0';
|
||||
stylesheet.dataset.vendooUxFoundation = '1.44.0';
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
const loadBehavior = () => {
|
||||
if (document.querySelector('script[data-vendoo-ux-foundation]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = '/ux-foundation.js?v=1.44.0';
|
||||
script.dataset.vendooUxFoundation = '1.44.0';
|
||||
script.async = false;
|
||||
document.body.append(script);
|
||||
};
|
||||
if (document.readyState === 'complete') loadBehavior();
|
||||
else window.addEventListener('load', loadBehavior, { once: true });
|
||||
}
|
||||
|
||||
function loadFluxStudioAssets() {
|
||||
if (!document.querySelector('link[data-ux-flux-studio]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/flux-studio-ux.css?v=1.44.0';
|
||||
stylesheet.setAttribute('data-ux-flux-studio', 'style');
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
const loadBehavior = () => {
|
||||
if (document.querySelector('script[data-ux-flux-studio]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = '/flux-studio-ux.js?v=1.44.0';
|
||||
script.setAttribute('data-ux-flux-studio', 'script');
|
||||
script.async = false;
|
||||
document.body.append(script);
|
||||
};
|
||||
if (document.readyState === 'complete') loadBehavior();
|
||||
else window.addEventListener('load', loadBehavior, { once: true });
|
||||
}
|
||||
|
||||
function loadQualityCenterAssets() {
|
||||
if (!document.querySelector('link[data-ux-quality-center]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/quality-center-ux.css?v=1.44.0';
|
||||
stylesheet.setAttribute('data-ux-quality-center', 'style');
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
const loadBehavior = () => {
|
||||
if (document.querySelector('script[data-ux-quality-center]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = '/quality-center-ux.js?v=1.44.0';
|
||||
script.setAttribute('data-ux-quality-center', 'script');
|
||||
script.async = false;
|
||||
document.body.append(script);
|
||||
};
|
||||
if (document.readyState === 'complete') loadBehavior();
|
||||
else window.addEventListener('load', loadBehavior, { once: true });
|
||||
}
|
||||
|
||||
function loadInventoryWorkspaceAssets() {
|
||||
if (!document.querySelector('link[data-ux-inventory-workspace]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/inventory-workspace.css?v=1.44.0';
|
||||
stylesheet.setAttribute('data-ux-inventory-workspace', 'style');
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
const loadBehavior = () => {
|
||||
if (document.querySelector('script[data-ux-inventory-workspace]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = '/inventory-workspace.js?v=1.44.0';
|
||||
script.setAttribute('data-ux-inventory-workspace', 'script');
|
||||
script.async = false;
|
||||
document.body.append(script);
|
||||
};
|
||||
if (document.readyState === 'complete') loadBehavior();
|
||||
else window.addEventListener('load', loadBehavior, { once: true });
|
||||
}
|
||||
|
||||
function loadTemplateWorkspaceAssets() {
|
||||
if (!document.querySelector('link[data-ux-template-workspace]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/template-workspace.css?v=1.44.0';
|
||||
stylesheet.setAttribute('data-ux-template-workspace', 'style');
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
const loadBehavior = () => {
|
||||
if (document.querySelector('script[data-ux-template-workspace]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = '/template-workspace.js?v=1.44.0';
|
||||
script.setAttribute('data-ux-template-workspace', 'script');
|
||||
script.async = false;
|
||||
document.body.append(script);
|
||||
};
|
||||
if (document.readyState === 'complete') loadBehavior();
|
||||
else window.addEventListener('load', loadBehavior, { once: true });
|
||||
}
|
||||
|
||||
function loadTodayWorkspaceAssets() {
|
||||
if (!document.querySelector('link[data-ux-today-workspace]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/today-workspace.css?v=1.44.0';
|
||||
stylesheet.setAttribute('data-ux-today-workspace', 'style');
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
const loadBehavior = () => {
|
||||
if (document.querySelector('script[data-ux-today-workspace]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = '/today-workspace.js?v=1.44.0';
|
||||
script.setAttribute('data-ux-today-workspace', 'script');
|
||||
script.async = false;
|
||||
document.body.append(script);
|
||||
};
|
||||
if (document.readyState === 'complete') loadBehavior();
|
||||
else window.addEventListener('load', loadBehavior, { once: true });
|
||||
}
|
||||
|
||||
function loadSettingsControlCenterAssets() {
|
||||
if (!document.querySelector('link[data-ux-settings-control-center]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/settings-control-center.css?v=1.44.0';
|
||||
stylesheet.setAttribute('data-ux-settings-control-center', 'style');
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
const loadBehavior = () => {
|
||||
if (document.querySelector('script[data-ux-settings-control-center]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = '/settings-control-center.js?v=1.44.0';
|
||||
script.setAttribute('data-ux-settings-control-center', 'script');
|
||||
script.async = false;
|
||||
document.body.append(script);
|
||||
};
|
||||
if (document.readyState === 'complete') loadBehavior();
|
||||
else window.addEventListener('load', loadBehavior, { once: true });
|
||||
}
|
||||
|
||||
function loadModuleExtensionWorkspaceAssets() {
|
||||
if (!document.querySelector('link[data-ux-module-extension-workspace]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/module-extension-workspace.css?v=1.44.0';
|
||||
stylesheet.setAttribute('data-ux-module-extension-workspace', 'style');
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
const loadBehavior = () => {
|
||||
if (document.querySelector('script[data-ux-module-extension-workspace]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = '/module-extension-workspace.js?v=1.44.0';
|
||||
script.setAttribute('data-ux-module-extension-workspace', 'script');
|
||||
script.async = false;
|
||||
document.body.append(script);
|
||||
};
|
||||
if (document.readyState === 'complete') loadBehavior();
|
||||
else window.addEventListener('load', loadBehavior, { once: true });
|
||||
}
|
||||
|
||||
applyState(readStoredState(), { persist: false, dispatch: false });
|
||||
loadUxFoundation();
|
||||
loadFluxStudioAssets();
|
||||
loadQualityCenterAssets();
|
||||
loadInventoryWorkspaceAssets();
|
||||
loadTemplateWorkspaceAssets();
|
||||
loadTodayWorkspaceAssets();
|
||||
loadSettingsControlCenterAssets();
|
||||
loadModuleExtensionWorkspaceAssets();
|
||||
window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener?.('change', () => {
|
||||
if (document.documentElement.dataset.themePreference === 'system') restoreStored();
|
||||
});
|
||||
@@ -87,4 +255,4 @@ window.VendooTheme = Object.freeze({
|
||||
return applyState({ ...current, theme_id: BUILTIN.has(themeId) ? themeId : 'system', custom_theme: null }, { persist });
|
||||
},
|
||||
supported: Object.freeze([...BUILTIN]),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
#flux-studio.ux-flux-studio-ready {
|
||||
--ux-flux-gap: clamp(14px, 1.6vw, 22px);
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-heading {
|
||||
align-items: flex-start;
|
||||
gap: var(--ux-flux-gap);
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-engine-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ux-flux-view-navigation {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 0 0 var(--ux-flux-gap);
|
||||
padding: 6px;
|
||||
border: 1px solid var(--vd-border, rgba(28, 25, 23, .12));
|
||||
border-radius: var(--vd-radius-lg, 16px);
|
||||
background: var(--vd-surface, #fff);
|
||||
box-shadow: var(--vd-shadow-sm, 0 8px 24px rgba(28, 25, 23, .06));
|
||||
}
|
||||
|
||||
.ux-flux-view-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 13px 15px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--vd-radius-lg, 16px) - 5px);
|
||||
background: transparent;
|
||||
color: var(--vd-text, #292524);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color .16s ease, border-color .16s ease, transform .16s ease;
|
||||
}
|
||||
|
||||
.ux-flux-view-tab:hover {
|
||||
background: var(--vd-surface-muted, rgba(120, 113, 108, .08));
|
||||
}
|
||||
|
||||
.ux-flux-view-tab.is-active {
|
||||
border-color: color-mix(in srgb, var(--vd-accent, #d85f35) 32%, transparent);
|
||||
background: color-mix(in srgb, var(--vd-accent, #d85f35) 10%, var(--vd-surface, #fff));
|
||||
}
|
||||
|
||||
.ux-flux-view-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ux-flux-view-copy strong {
|
||||
font-size: .93rem;
|
||||
}
|
||||
|
||||
.ux-flux-view-copy small {
|
||||
overflow: hidden;
|
||||
color: var(--vd-text-muted, #78716c);
|
||||
font-size: .76rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ux-flux-view-badge:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-flux-view-badge {
|
||||
flex: 0 0 auto;
|
||||
min-width: 26px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--vd-surface-strong, #f1ede9);
|
||||
color: var(--vd-text-muted, #57534e);
|
||||
font-size: .7rem;
|
||||
font-weight: 750;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ux-flux-view-badge[data-tone="working"] {
|
||||
background: color-mix(in srgb, var(--vd-warning, #c9801c) 16%, transparent);
|
||||
color: var(--vd-warning, #9a5b0c);
|
||||
}
|
||||
|
||||
.ux-flux-view-badge[data-tone="danger"] {
|
||||
background: color-mix(in srgb, var(--vd-danger, #c44235) 14%, transparent);
|
||||
color: var(--vd-danger, #b1362c);
|
||||
}
|
||||
|
||||
.ux-flux-workflow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, .85fr) minmax(480px, 1.8fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--ux-flux-gap);
|
||||
margin-bottom: var(--ux-flux-gap);
|
||||
padding: clamp(16px, 2vw, 24px);
|
||||
border: 1px solid var(--vd-border, rgba(28, 25, 23, .12));
|
||||
border-radius: var(--vd-radius-xl, 20px);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--vd-accent, #d85f35) 8%, var(--vd-surface, #fff)), var(--vd-surface, #fff));
|
||||
box-shadow: var(--vd-shadow-sm, 0 8px 24px rgba(28, 25, 23, .06));
|
||||
}
|
||||
|
||||
.ux-flux-workflow-copy {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ux-flux-kicker {
|
||||
color: var(--vd-accent, #d85f35);
|
||||
font-size: .7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ux-flux-workflow-copy h3,
|
||||
.ux-flux-workflow-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ux-flux-workflow-copy h3 {
|
||||
font-size: clamp(1rem, 1.4vw, 1.22rem);
|
||||
}
|
||||
|
||||
.ux-flux-workflow-copy p {
|
||||
color: var(--vd-text-muted, #78716c);
|
||||
font-size: .82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ux-flux-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ux-flux-step {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 2px 8px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--vd-border, rgba(28, 25, 23, .1));
|
||||
border-radius: var(--vd-radius-md, 12px);
|
||||
background: color-mix(in srgb, var(--vd-surface, #fff) 78%, transparent);
|
||||
color: var(--vd-text, #292524);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ux-flux-step-index {
|
||||
grid-row: 1 / span 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50%;
|
||||
background: var(--vd-surface-strong, #eee9e4);
|
||||
color: var(--vd-text-muted, #57534e);
|
||||
font-size: .7rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ux-flux-step strong {
|
||||
overflow: hidden;
|
||||
font-size: .78rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ux-flux-step small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-flux-step-state {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 5px;
|
||||
color: var(--vd-text-muted, #78716c);
|
||||
font-size: .65rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ux-flux-step.is-current {
|
||||
border-color: color-mix(in srgb, var(--vd-accent, #d85f35) 48%, transparent);
|
||||
background: color-mix(in srgb, var(--vd-accent, #d85f35) 11%, var(--vd-surface, #fff));
|
||||
}
|
||||
|
||||
.ux-flux-step.is-current .ux-flux-step-index {
|
||||
background: var(--vd-accent, #d85f35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ux-flux-step.is-complete:not(.is-current) .ux-flux-step-index {
|
||||
background: color-mix(in srgb, var(--vd-success, #2f8a57) 18%, transparent);
|
||||
color: var(--vd-success, #257347);
|
||||
}
|
||||
|
||||
.ux-flux-next {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ux-flux-system-panel {
|
||||
margin-bottom: var(--ux-flux-gap);
|
||||
border: 1px solid var(--vd-border, rgba(28, 25, 23, .12));
|
||||
border-radius: var(--vd-radius-lg, 16px);
|
||||
background: var(--vd-surface, #fff);
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.ux-flux-system-panel > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 17px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ux-flux-system-panel > summary::-webkit-details-marker,
|
||||
.ux-flux-advanced > summary::-webkit-details-marker,
|
||||
.ux-flux-preview-more > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-flux-system-summary-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ux-flux-system-summary-copy small {
|
||||
color: var(--vd-text-muted, #78716c);
|
||||
font-size: .76rem;
|
||||
}
|
||||
|
||||
.ux-flux-system-body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 0 17px 17px;
|
||||
border-top: 1px solid var(--vd-border, rgba(28, 25, 23, .08));
|
||||
}
|
||||
|
||||
.ux-flux-system-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.ux-flux-system-body .flux-runtime-card {
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-pro-layout {
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(360px, .85fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-left-stack,
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-side-stack {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-controls {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-prompt-field textarea {
|
||||
min-height: 150px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-profile-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-profile-strip button {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ux-flux-advanced {
|
||||
border: 1px solid var(--vd-border, rgba(28, 25, 23, .1));
|
||||
border-radius: var(--vd-radius-md, 12px);
|
||||
background: var(--vd-surface-muted, rgba(120, 113, 108, .05));
|
||||
}
|
||||
|
||||
.ux-flux-advanced > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 14px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ux-flux-advanced > summary > span:first-child {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ux-flux-advanced > summary small,
|
||||
.ux-flux-advanced-hint {
|
||||
color: var(--vd-text-muted, #78716c);
|
||||
font-size: .72rem;
|
||||
}
|
||||
|
||||
.ux-flux-advanced-hint {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--vd-surface-strong, #eee9e4);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ux-flux-advanced-body {
|
||||
display: grid;
|
||||
gap: 13px;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.ux-flux-advanced-body .flux-lock-toggle {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-preview-card {
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-preview {
|
||||
min-height: clamp(320px, 42vw, 560px);
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-preview-actions {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.ux-flux-preview-primary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ux-flux-preview-primary .vd-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ux-flux-preview-more {
|
||||
border-top: 1px solid var(--vd-border, rgba(28, 25, 23, .09));
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.ux-flux-preview-more > summary {
|
||||
padding: 7px 4px;
|
||||
color: var(--vd-text-muted, #78716c);
|
||||
font-size: .78rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ux-flux-preview-more-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.ux-flux-job-quick-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin: 0 0 13px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.ux-flux-job-quick-filters .vd-button.is-active {
|
||||
border-color: color-mix(in srgb, var(--vd-accent, #d85f35) 44%, transparent);
|
||||
background: color-mix(in srgb, var(--vd-accent, #d85f35) 11%, var(--vd-surface, #fff));
|
||||
color: var(--vd-accent, #b84b26);
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-job-center,
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-history-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-job-center-actions {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-job-bulkbar {
|
||||
transition: opacity .16s ease;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-job-bulkbar:not(:has(input:checked)) > div button:disabled {
|
||||
opacity: .46;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-history {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-history-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ux-flux-panel-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ux-flux-view-tab:focus-visible,
|
||||
.ux-flux-step:focus-visible,
|
||||
.ux-flux-system-panel > summary:focus-visible,
|
||||
.ux-flux-advanced > summary:focus-visible,
|
||||
.ux-flux-preview-more > summary:focus-visible,
|
||||
.ux-flux-job-quick-filters button:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--vd-accent, #d85f35) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.ux-flux-workflow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ux-flux-next {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-pro-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-preview-card {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.ux-flux-view-navigation {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ux-flux-view-tab {
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.ux-flux-steps {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-profile-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ux-flux-preview-primary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-job-center-head,
|
||||
#flux-studio.ux-flux-studio-ready .flux-studio-history-head {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready .flux-job-center-actions,
|
||||
#flux-studio.ux-flux-studio-ready .flux-library-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 470px) {
|
||||
.ux-flux-workflow {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.ux-flux-steps,
|
||||
#flux-studio.ux-flux-studio-ready .flux-profile-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ux-flux-step {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ux-flux-step-state {
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ux-flux-system-panel > summary {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ux-flux-system-actions,
|
||||
.ux-flux-preview-more-body,
|
||||
.ux-flux-job-quick-filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ux-flux-system-actions .vd-button,
|
||||
.ux-flux-preview-more-body .vd-button,
|
||||
.ux-flux-preview-more-body a,
|
||||
.ux-flux-job-quick-filters .vd-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ux-flux-view-tab,
|
||||
#flux-studio.ux-flux-studio-ready .flux-job-bulkbar {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#flux-studio.ux-flux-studio-ready * {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const ROOT_ID = 'flux-studio';
|
||||
const VIEW_KEY = 'vendoo_flux_studio_view';
|
||||
const VALID_VIEWS = ['create', 'jobs', 'library'];
|
||||
|
||||
const createNode = (tag, className = '', text = '') => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
const setText = (node, value) => {
|
||||
if (!node) return;
|
||||
const normalized = String(value ?? '');
|
||||
if (node.textContent !== normalized) node.textContent = normalized;
|
||||
};
|
||||
|
||||
const setData = (node, name, value) => {
|
||||
if (!node) return;
|
||||
const normalized = String(value ?? '');
|
||||
if (node.dataset[name] !== normalized) node.dataset[name] = normalized;
|
||||
};
|
||||
|
||||
const readStoredView = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(VIEW_KEY) || 'create';
|
||||
return VALID_VIEWS.includes(stored) ? stored : 'create';
|
||||
} catch {
|
||||
return 'create';
|
||||
}
|
||||
};
|
||||
|
||||
const storeView = view => {
|
||||
try { localStorage.setItem(VIEW_KEY, view); } catch {}
|
||||
};
|
||||
|
||||
const focusNode = node => {
|
||||
if (!node) return;
|
||||
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
window.requestAnimationFrame(() => {
|
||||
const target = node.matches('input,button,select,textarea,summary,a[href]')
|
||||
? node
|
||||
: node.querySelector('input,button,select,textarea,summary,a[href]');
|
||||
target?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
|
||||
function createViewButton(view, label, copy) {
|
||||
const button = createNode('button', 'ux-flux-view-tab');
|
||||
button.type = 'button';
|
||||
button.dataset.uxFluxView = view;
|
||||
button.setAttribute('role', 'tab');
|
||||
button.setAttribute('aria-selected', 'false');
|
||||
|
||||
const body = createNode('span', 'ux-flux-view-copy');
|
||||
body.append(createNode('strong', '', label), createNode('small', '', copy));
|
||||
const badge = createNode('span', 'ux-flux-view-badge');
|
||||
badge.dataset.uxFluxViewBadge = view;
|
||||
button.append(body, badge);
|
||||
return button;
|
||||
}
|
||||
|
||||
function createViewNavigation(root) {
|
||||
if (root.querySelector('.ux-flux-view-navigation')) return;
|
||||
const header = root.querySelector('.flux-studio-heading');
|
||||
const layout = root.querySelector('.flux-studio-layout');
|
||||
if (!header || !layout) return;
|
||||
|
||||
const navigation = createNode('nav', 'ux-flux-view-navigation');
|
||||
navigation.setAttribute('aria-label', 'FLUX Studio Arbeitsbereiche');
|
||||
navigation.setAttribute('role', 'tablist');
|
||||
navigation.append(
|
||||
createViewButton('create', 'Erstellen', 'Prompt, Profil und Vorschau'),
|
||||
createViewButton('jobs', 'Aufträge', 'Warteschlange, Fehler und Archiv'),
|
||||
createViewButton('library', 'Bibliothek', 'Ergebnisse und Favoriten')
|
||||
);
|
||||
header.after(navigation);
|
||||
}
|
||||
|
||||
function setView(root, view, { persist = true, focus = false } = {}) {
|
||||
const normalized = VALID_VIEWS.includes(view) ? view : 'create';
|
||||
setData(root, 'uxFluxView', normalized);
|
||||
if (persist) storeView(normalized);
|
||||
|
||||
root.querySelectorAll('[data-ux-flux-view]').forEach(button => {
|
||||
const active = button.dataset.uxFluxView === normalized;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-selected', String(active));
|
||||
button.tabIndex = active ? 0 : -1;
|
||||
});
|
||||
|
||||
const panels = {
|
||||
create: root.querySelector('.flux-studio-layout'),
|
||||
jobs: root.querySelector('.flux-job-center'),
|
||||
library: root.querySelector('.flux-studio-history-section'),
|
||||
};
|
||||
Object.entries(panels).forEach(([id, panel]) => {
|
||||
if (!panel) return;
|
||||
const active = id === normalized;
|
||||
panel.classList.toggle('ux-flux-panel-hidden', !active);
|
||||
panel.setAttribute('aria-hidden', String(!active));
|
||||
});
|
||||
|
||||
if (focus) {
|
||||
const target = normalized === 'create'
|
||||
? root.querySelector('#flux-studio-prompt')
|
||||
: normalized === 'jobs'
|
||||
? root.querySelector('#flux-job-center-toggle, #flux-job-center-body')
|
||||
: root.querySelector('#flux-studio-favorites-only, #flux-studio-history');
|
||||
focusNode(target);
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkflow(root) {
|
||||
if (root.querySelector('.ux-flux-workflow')) return;
|
||||
const navigation = root.querySelector('.ux-flux-view-navigation');
|
||||
if (!navigation) return;
|
||||
|
||||
const workflow = createNode('section', 'ux-flux-workflow');
|
||||
workflow.setAttribute('aria-label', 'Geführter FLUX-Ablauf');
|
||||
|
||||
const copy = createNode('div', 'ux-flux-workflow-copy');
|
||||
copy.append(
|
||||
createNode('span', 'ux-flux-kicker', 'Geführte Bildgenerierung'),
|
||||
createNode('h3', '', 'Vom Prompt zum verwendbaren Produktbild'),
|
||||
createNode('p', '', 'Die wichtigsten Schritte bleiben sichtbar. Systemdetails, Auftragsverwaltung und Bibliothek sind getrennt erreichbar.')
|
||||
);
|
||||
|
||||
const steps = createNode('div', 'ux-flux-steps');
|
||||
const definitions = [
|
||||
['prompt', '1', 'Prompt', 'Motiv und Bildsprache beschreiben'],
|
||||
['profile', '2', 'Profil', 'Varianten und Qualitätsziel wählen'],
|
||||
['result', '3', 'Ergebnis', 'Bild prüfen und übernehmen'],
|
||||
['monitor', '4', 'Betrieb', 'Aufträge und Fehler überwachen'],
|
||||
];
|
||||
definitions.forEach(([id, number, title, detail]) => {
|
||||
const button = createNode('button', 'ux-flux-step');
|
||||
button.type = 'button';
|
||||
button.dataset.uxFluxStep = id;
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
button.append(
|
||||
createNode('span', 'ux-flux-step-index', number),
|
||||
createNode('strong', '', title),
|
||||
createNode('small', '', detail),
|
||||
createNode('span', 'ux-flux-step-state', 'Offen')
|
||||
);
|
||||
steps.append(button);
|
||||
});
|
||||
|
||||
const next = createNode('button', 'vd-button primary ux-flux-next', 'Prompt eingeben');
|
||||
next.type = 'button';
|
||||
next.dataset.uxFluxNext = 'prompt';
|
||||
workflow.append(copy, steps, next);
|
||||
navigation.after(workflow);
|
||||
}
|
||||
|
||||
function createSystemPanel(root) {
|
||||
if (root.querySelector('.ux-flux-system-panel')) return;
|
||||
const headingActions = root.querySelector('.flux-studio-engine-actions');
|
||||
const workflow = root.querySelector('.ux-flux-workflow');
|
||||
const runtime = root.querySelector('.flux-runtime-card');
|
||||
if (!headingActions || !workflow || !runtime) return;
|
||||
|
||||
const reset = headingActions.querySelector('#flux-studio-reset');
|
||||
const chip = headingActions.querySelector('#flux-studio-engine-chip');
|
||||
const controls = [
|
||||
headingActions.querySelector('#flux-studio-refresh-engine'),
|
||||
headingActions.querySelector('#flux-studio-start-engine'),
|
||||
headingActions.querySelector('#flux-studio-stop-engine'),
|
||||
].filter(Boolean);
|
||||
|
||||
const panel = createNode('details', 'ux-flux-system-panel');
|
||||
const summary = createNode('summary');
|
||||
const summaryCopy = createNode('span', 'ux-flux-system-summary-copy');
|
||||
summaryCopy.append(
|
||||
createNode('strong', '', 'FLUX-System & ComfyUI'),
|
||||
createNode('small', '', 'Engine-Status, Start/Stop und Laufzeitdaten')
|
||||
);
|
||||
if (chip) summary.append(summaryCopy, chip);
|
||||
else summary.append(summaryCopy);
|
||||
|
||||
const body = createNode('div', 'ux-flux-system-body');
|
||||
const actionBar = createNode('div', 'ux-flux-system-actions');
|
||||
controls.forEach(button => actionBar.append(button));
|
||||
body.append(actionBar, runtime);
|
||||
panel.append(summary, body);
|
||||
workflow.after(panel);
|
||||
|
||||
headingActions.replaceChildren();
|
||||
if (reset) headingActions.append(reset);
|
||||
}
|
||||
|
||||
function groupAdvancedControls(root) {
|
||||
const controls = root.querySelector('.flux-studio-controls');
|
||||
if (!controls || controls.querySelector('.ux-flux-advanced')) return;
|
||||
const fields = controls.querySelector('.flux-studio-fields');
|
||||
const lock = controls.querySelector('.flux-lock-toggle');
|
||||
const runRow = controls.querySelector('.flux-studio-run-row');
|
||||
if (!fields || !runRow) return;
|
||||
|
||||
const advanced = createNode('details', 'ux-flux-advanced');
|
||||
const summary = createNode('summary');
|
||||
const copy = createNode('span');
|
||||
copy.append(
|
||||
createNode('strong', '', 'Erweiterte Einstellungen'),
|
||||
createNode('small', '', 'Prompt-Modus, Stil, Format, Schritte und Seed')
|
||||
);
|
||||
summary.append(copy, createNode('span', 'ux-flux-advanced-hint', 'Optional'));
|
||||
const body = createNode('div', 'ux-flux-advanced-body');
|
||||
if (lock) body.append(lock);
|
||||
body.append(fields);
|
||||
advanced.append(summary, body);
|
||||
runRow.before(advanced);
|
||||
}
|
||||
|
||||
function simplifyPreviewActions(root) {
|
||||
const actions = root.querySelector('#flux-studio-preview-actions');
|
||||
if (!actions || actions.dataset.uxFluxStructured === 'true') return;
|
||||
|
||||
const primary = createNode('div', 'ux-flux-preview-primary');
|
||||
const more = createNode('details', 'ux-flux-preview-more');
|
||||
const summary = createNode('summary', '', 'Weitere Bildaktionen');
|
||||
const moreBody = createNode('div', 'ux-flux-preview-more-body');
|
||||
|
||||
const edit = actions.querySelector('#flux-studio-edit');
|
||||
const use = actions.querySelector('#flux-studio-use-in-generator');
|
||||
const similar = actions.querySelector('#flux-studio-similar');
|
||||
const favorite = actions.querySelector('#flux-studio-favorite');
|
||||
const download = actions.querySelector('#flux-studio-download');
|
||||
const remove = actions.querySelector('#flux-studio-delete');
|
||||
|
||||
if (use) primary.append(use);
|
||||
if (edit) primary.append(edit);
|
||||
[similar, favorite, download, remove].filter(Boolean).forEach(node => moreBody.append(node));
|
||||
more.append(summary, moreBody);
|
||||
actions.append(primary, more);
|
||||
actions.dataset.uxFluxStructured = 'true';
|
||||
}
|
||||
|
||||
function createJobQuickFilters(root) {
|
||||
const center = root.querySelector('.flux-job-center');
|
||||
const select = root.querySelector('#flux-job-filter');
|
||||
if (!center || !select || center.querySelector('.ux-flux-job-quick-filters')) return;
|
||||
|
||||
const filters = createNode('div', 'ux-flux-job-quick-filters');
|
||||
filters.setAttribute('role', 'group');
|
||||
filters.setAttribute('aria-label', 'FLUX-Aufträge schnell filtern');
|
||||
const definitions = [
|
||||
['', 'Alle'],
|
||||
['active', 'Aktiv'],
|
||||
['completed', 'Erfolgreich'],
|
||||
['failed', 'Fehler'],
|
||||
['cancelled', 'Abgebrochen'],
|
||||
];
|
||||
definitions.forEach(([value, label]) => {
|
||||
const button = createNode('button', 'vd-button compact', label);
|
||||
button.type = 'button';
|
||||
button.dataset.uxFluxJobFilter = value || 'all';
|
||||
button.addEventListener('click', () => {
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
updateJobFilters(root);
|
||||
});
|
||||
filters.append(button);
|
||||
});
|
||||
|
||||
const head = center.querySelector('.flux-job-center-head');
|
||||
head?.after(filters);
|
||||
}
|
||||
|
||||
function updateJobFilters(root) {
|
||||
const value = root.querySelector('#flux-job-filter')?.value || '';
|
||||
root.querySelectorAll('[data-ux-flux-job-filter]').forEach(button => {
|
||||
const active = button.dataset.uxFluxJobFilter === (value || 'all');
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
}
|
||||
|
||||
function readJobState(root) {
|
||||
const summary = root.querySelector('#flux-job-summary');
|
||||
const text = (summary?.textContent || '').toLowerCase();
|
||||
const listText = (root.querySelector('#flux-job-list')?.textContent || '').toLowerCase();
|
||||
const combined = `${text} ${listText}`;
|
||||
const active = /wartende varianten\s*[1-9]|laufende varianten\s*[1-9]|wartend|läuft|running|queued|cancelling/.test(combined);
|
||||
const issues = /fehlgeschlagen|fehler|failed|teilweise fertig|completed_with_errors/.test(combined);
|
||||
const hasJobs = Boolean(root.querySelector('#flux-job-list')?.children.length) && !/keine aufträge|werden geladen/.test(listText);
|
||||
return { active, issues, hasJobs };
|
||||
}
|
||||
|
||||
function readLibraryCount(root) {
|
||||
const info = root.querySelector('#flux-history-page-info')?.textContent || '';
|
||||
const explicit = info.match(/·\s*(\d+)\s+Bilder?/i);
|
||||
if (explicit) return Number(explicit[1]);
|
||||
const history = root.querySelector('#flux-studio-history');
|
||||
if (!history || /keine flux-bilder|wird geladen/.test((history.textContent || '').toLowerCase())) return 0;
|
||||
return [...history.children].filter(node => !node.classList.contains('empty-state')).length;
|
||||
}
|
||||
|
||||
function updateBadges(root) {
|
||||
const jobState = readJobState(root);
|
||||
const jobBadge = root.querySelector('[data-ux-flux-view-badge="jobs"]');
|
||||
if (jobBadge) {
|
||||
setText(jobBadge, jobState.issues ? 'Problem' : jobState.active ? 'Aktiv' : jobState.hasJobs ? 'Verlauf' : '');
|
||||
setData(jobBadge, 'tone', jobState.issues ? 'danger' : jobState.active ? 'working' : 'neutral');
|
||||
}
|
||||
|
||||
const libraryBadge = root.querySelector('[data-ux-flux-view-badge="library"]');
|
||||
if (libraryBadge) {
|
||||
const count = readLibraryCount(root);
|
||||
setText(libraryBadge, count ? String(count) : '');
|
||||
setData(libraryBadge, 'tone', 'neutral');
|
||||
}
|
||||
}
|
||||
|
||||
function updateWorkflow(root) {
|
||||
const prompt = root.querySelector('#flux-studio-prompt')?.value.trim() || '';
|
||||
const profile = root.querySelector('[data-flux-profile].active') || root.querySelector('[data-flux-profile="balanced"]');
|
||||
const variants = root.querySelector('[data-flux-variants].active') || root.querySelector('[data-flux-variants="1"]');
|
||||
const image = root.querySelector('#flux-studio-preview-image');
|
||||
const hasResult = Boolean(image?.getAttribute('src')) && !image.classList.contains('hidden');
|
||||
const progress = root.querySelector('#flux-studio-progress');
|
||||
const isBusy = Boolean(progress && !progress.classList.contains('hidden'));
|
||||
const jobState = readJobState(root);
|
||||
|
||||
const completed = {
|
||||
prompt: prompt.length >= 3,
|
||||
profile: Boolean(profile && variants),
|
||||
result: hasResult,
|
||||
monitor: jobState.hasJobs && !jobState.active && !jobState.issues,
|
||||
};
|
||||
|
||||
const current = !completed.prompt
|
||||
? 'prompt'
|
||||
: jobState.issues || jobState.active || isBusy
|
||||
? 'monitor'
|
||||
: hasResult
|
||||
? 'result'
|
||||
: 'profile';
|
||||
|
||||
setData(root, 'uxFluxCurrentStep', current);
|
||||
root.querySelectorAll('[data-ux-flux-step]').forEach(step => {
|
||||
const id = step.dataset.uxFluxStep;
|
||||
const active = id === current;
|
||||
step.classList.toggle('is-current', active);
|
||||
step.classList.toggle('is-complete', Boolean(completed[id]));
|
||||
step.setAttribute('aria-pressed', String(active));
|
||||
setText(step.querySelector('.ux-flux-step-state'), completed[id] ? 'Erledigt' : active ? 'Jetzt' : 'Offen');
|
||||
});
|
||||
|
||||
const next = root.querySelector('[data-ux-flux-next]');
|
||||
if (next) {
|
||||
if (!completed.prompt) {
|
||||
setData(next, 'uxFluxNext', 'prompt');
|
||||
setText(next, 'Prompt eingeben');
|
||||
} else if (jobState.issues || jobState.active || isBusy) {
|
||||
setData(next, 'uxFluxNext', 'jobs');
|
||||
setText(next, jobState.issues ? 'Probleme prüfen' : 'Auftrag überwachen');
|
||||
} else if (hasResult) {
|
||||
setData(next, 'uxFluxNext', 'result');
|
||||
setText(next, 'Ergebnis prüfen');
|
||||
} else {
|
||||
setData(next, 'uxFluxNext', 'generate');
|
||||
setText(next, 'Profil prüfen & Auftrag starten');
|
||||
}
|
||||
}
|
||||
|
||||
updateBadges(root);
|
||||
updateJobFilters(root);
|
||||
}
|
||||
|
||||
function handleStep(root, step) {
|
||||
if (step === 'jobs' || step === 'monitor') {
|
||||
setView(root, 'jobs', { focus: true });
|
||||
return;
|
||||
}
|
||||
if (step === 'library') {
|
||||
setView(root, 'library', { focus: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setView(root, 'create');
|
||||
const targets = {
|
||||
prompt: root.querySelector('#flux-studio-prompt'),
|
||||
profile: root.querySelector('.flux-profile-strip'),
|
||||
generate: root.querySelector('#flux-studio-generate'),
|
||||
result: root.querySelector('.flux-studio-preview-card'),
|
||||
};
|
||||
focusNode(targets[step] || targets.prompt);
|
||||
}
|
||||
|
||||
function bindInteractions(root) {
|
||||
root.querySelectorAll('[data-ux-flux-view]').forEach(button => {
|
||||
button.addEventListener('click', () => setView(root, button.dataset.uxFluxView, { focus: true }));
|
||||
button.addEventListener('keydown', event => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const tabs = [...root.querySelectorAll('[data-ux-flux-view]')];
|
||||
const index = tabs.indexOf(button);
|
||||
const nextIndex = event.key === 'Home' ? 0
|
||||
: event.key === 'End' ? tabs.length - 1
|
||||
: event.key === 'ArrowRight' ? (index + 1) % tabs.length
|
||||
: (index - 1 + tabs.length) % tabs.length;
|
||||
tabs[nextIndex]?.focus();
|
||||
tabs[nextIndex]?.click();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll('[data-ux-flux-step]').forEach(button => {
|
||||
button.addEventListener('click', () => handleStep(root, button.dataset.uxFluxStep));
|
||||
});
|
||||
root.querySelector('[data-ux-flux-next]')?.addEventListener('click', event => {
|
||||
handleStep(root, event.currentTarget.dataset.uxFluxNext);
|
||||
});
|
||||
root.querySelector('#flux-job-filter')?.addEventListener('change', () => updateJobFilters(root));
|
||||
}
|
||||
|
||||
function observeState(root) {
|
||||
let scheduled = false;
|
||||
const schedule = () => {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
updateWorkflow(root);
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(schedule);
|
||||
const targets = [
|
||||
root.querySelector('#flux-job-summary'),
|
||||
root.querySelector('#flux-job-list'),
|
||||
root.querySelector('#flux-history-page-info'),
|
||||
root.querySelector('#flux-studio-history'),
|
||||
root.querySelector('#flux-studio-preview-image'),
|
||||
root.querySelector('#flux-studio-progress'),
|
||||
root.querySelector('#flux-studio-status'),
|
||||
].filter(Boolean);
|
||||
targets.forEach(target => observer.observe(target, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'src', 'data-state', 'data-tone', 'disabled'],
|
||||
}));
|
||||
root.querySelectorAll('input,textarea,select').forEach(field => {
|
||||
field.addEventListener('input', schedule);
|
||||
field.addEventListener('change', schedule);
|
||||
});
|
||||
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
|
||||
}
|
||||
|
||||
function initializeFluxStudioUx() {
|
||||
const root = document.getElementById(ROOT_ID);
|
||||
if (!root || root.classList.contains('ux-flux-studio-ready')) return;
|
||||
|
||||
createViewNavigation(root);
|
||||
createWorkflow(root);
|
||||
createSystemPanel(root);
|
||||
groupAdvancedControls(root);
|
||||
simplifyPreviewActions(root);
|
||||
createJobQuickFilters(root);
|
||||
bindInteractions(root);
|
||||
setView(root, readStoredView(), { persist: false });
|
||||
updateWorkflow(root);
|
||||
observeState(root);
|
||||
root.classList.add('ux-flux-studio-ready');
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeFluxStudioUx, { once: true });
|
||||
} else {
|
||||
initializeFluxStudioUx();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,459 @@
|
||||
/* Vendoo Generator Workflow 1.44.0
|
||||
* Geführter Drei-Schritt-Ablauf für neue Artikel.
|
||||
*/
|
||||
|
||||
.ux-generator-guided .generator-heading {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ux-generator-flow {
|
||||
background: linear-gradient(135deg, var(--bg-card), var(--bg-page));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--vd-radius-xl);
|
||||
box-shadow: var(--vd-shadow-soft);
|
||||
margin-bottom: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ux-generator-flow-head {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 320px);
|
||||
padding: 22px 24px 17px;
|
||||
}
|
||||
|
||||
.ux-generator-flow-kicker {
|
||||
color: var(--accent);
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .1em;
|
||||
margin-bottom: 7px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ux-generator-flow-head h3 {
|
||||
font-size: clamp(20px, 2vw, 27px);
|
||||
letter-spacing: -.035em;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 7px;
|
||||
}
|
||||
|
||||
.ux-generator-flow-head p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.ux-generator-overall {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--vd-radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 76px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.ux-generator-overall strong {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ux-generator-overall span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ux-generator-progress {
|
||||
background: color-mix(in srgb, var(--border) 72%, transparent);
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ux-generator-progress span {
|
||||
background: linear-gradient(90deg, var(--accent), var(--success));
|
||||
display: block;
|
||||
height: 100%;
|
||||
transition: width .28s ease;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.ux-generator-stages {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
background: var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ux-generator-stage {
|
||||
align-items: center;
|
||||
background: var(--bg-card);
|
||||
border: 0;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
font: inherit;
|
||||
gap: 11px;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
min-height: 84px;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
transition: background .16s ease, color .16s ease;
|
||||
}
|
||||
|
||||
.ux-generator-stage:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ux-generator-stage.is-current {
|
||||
background: var(--accent-light);
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 -3px 0 var(--accent);
|
||||
}
|
||||
|
||||
.ux-generator-stage.is-complete {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ux-generator-stage-number {
|
||||
align-items: center;
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.ux-generator-stage.is-current .ux-generator-stage-number {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ux-generator-stage.is-complete .ux-generator-stage-number {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ux-generator-stage > span:nth-child(2) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ux-generator-stage strong {
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ux-generator-stage small {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ux-generator-stage-state {
|
||||
background: var(--bg-page);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
padding: 5px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ux-generator-stage.is-current .ux-generator-stage-state {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ux-generator-stage.is-complete .ux-generator-stage-state {
|
||||
background: var(--vd-success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.ux-generator-next {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
justify-content: space-between;
|
||||
padding: 15px 18px;
|
||||
}
|
||||
|
||||
.ux-generator-next-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ux-generator-next-copy > span {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ux-generator-next-copy strong {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ux-generator-next-copy small {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-commandbar {
|
||||
backdrop-filter: blur(12px);
|
||||
background: color-mix(in srgb, var(--bg-card) 92%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--vd-radius-lg);
|
||||
box-shadow: var(--vd-shadow-soft);
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-studio-layout {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, .92fr) minmax(0, 1.08fr);
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-preview-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-panel {
|
||||
border-radius: var(--vd-radius-xl);
|
||||
min-width: 0;
|
||||
scroll-margin-top: 150px;
|
||||
transition: border-color .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-panel.ux-generator-panel-focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-light), var(--vd-shadow-soft);
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-config-stack {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-config-card,
|
||||
.ux-generator-guided .generator-notes-card {
|
||||
border-radius: var(--vd-radius-lg);
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-config-card.ux-generator-essential {
|
||||
border-color: color-mix(in srgb, var(--accent) 20%, var(--border));
|
||||
}
|
||||
|
||||
.ux-generator-advanced,
|
||||
.ux-generator-optional {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--vd-radius-lg);
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.ux-generator-advanced > summary,
|
||||
.ux-generator-optional > summary {
|
||||
align-items: center;
|
||||
background: var(--bg-page);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: space-between;
|
||||
list-style: none;
|
||||
min-height: 64px;
|
||||
padding: 13px 15px;
|
||||
}
|
||||
|
||||
.ux-generator-advanced > summary::-webkit-details-marker,
|
||||
.ux-generator-optional > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-generator-advanced > summary > span,
|
||||
.ux-generator-optional > summary > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ux-generator-advanced > summary strong,
|
||||
.ux-generator-optional > summary strong {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ux-generator-advanced > summary small,
|
||||
.ux-generator-optional > summary small {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ux-generator-advanced > summary b,
|
||||
.ux-generator-optional > summary b {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
max-width: 48%;
|
||||
overflow: hidden;
|
||||
padding: 5px 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ux-generator-advanced > summary::after,
|
||||
.ux-generator-optional > summary::after {
|
||||
color: var(--text-muted);
|
||||
content: '›';
|
||||
flex: 0 0 auto;
|
||||
font-size: 18px;
|
||||
margin-left: -5px;
|
||||
transform: rotate(90deg);
|
||||
transition: transform .16s ease;
|
||||
}
|
||||
|
||||
.ux-generator-advanced:not([open]) > summary::after,
|
||||
.ux-generator-optional:not([open]) > summary::after {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.ux-generator-advanced-content,
|
||||
.ux-generator-optional-content {
|
||||
background: var(--bg-card);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.ux-generator-advanced-content > .generator-config-card,
|
||||
.ux-generator-advanced-content > .generator-config-pair,
|
||||
.ux-generator-advanced-content > .generator-notes-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-ai-model-card,
|
||||
.ux-generator-guided .generator-photo-quality {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-photo-strip:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-preview-panel .generator-result {
|
||||
margin-inline: auto;
|
||||
max-width: 1180px;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-preview-empty {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-html-details:not([open]) {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-html-details > summary {
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.ux-generator-guided [data-ux-generator-stage]:focus-visible,
|
||||
.ux-generator-guided [data-ux-generator-action]:focus-visible,
|
||||
.ux-generator-guided .ux-generator-advanced > summary:focus-visible,
|
||||
.ux-generator-guided .ux-generator-optional > summary:focus-visible,
|
||||
.ux-generator-guided #dropzone:focus-visible {
|
||||
outline: 3px solid var(--accent-light);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.ux-generator-flow-head {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ux-generator-overall {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-studio-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-preview-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.ux-generator-flow-head {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.ux-generator-stages {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ux-generator-stage {
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.ux-generator-next {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ux-generator-next .vd-button {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ux-generator-guided .generator-commandbar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.ux-generator-advanced > summary,
|
||||
.ux-generator-optional > summary {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ux-generator-advanced > summary b,
|
||||
.ux-generator-optional > summary b {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ux-generator-progress span,
|
||||
.ux-generator-stage,
|
||||
.ux-generator-panel,
|
||||
.ux-generator-advanced > summary::after,
|
||||
.ux-generator-optional > summary::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const root = document.getElementById('generator');
|
||||
if (!root || root.dataset.uxGeneratorWorkflow === 'ready') return;
|
||||
root.dataset.uxGeneratorWorkflow = 'ready';
|
||||
root.classList.add('ux-generator-guided');
|
||||
|
||||
const STORAGE = {
|
||||
advanced: 'vendoo_ux_generator_advanced_open',
|
||||
photoHelp: 'vendoo_ux_generator_photo_help_open',
|
||||
imageStudio: 'vendoo_ux_generator_image_studio_open',
|
||||
htmlPreview: 'vendoo_ux_generator_html_preview_open',
|
||||
};
|
||||
|
||||
const stageTargets = {
|
||||
photos: '.media-workbench',
|
||||
settings: '.generator-config-panel',
|
||||
result: '.generator-preview-panel',
|
||||
};
|
||||
|
||||
let flow = null;
|
||||
let updateScheduled = false;
|
||||
|
||||
function setText(element, value) {
|
||||
if (element && element.textContent !== value) element.textContent = value;
|
||||
}
|
||||
|
||||
function isVisible(element) {
|
||||
return !!element && !element.classList.contains('hidden') && !element.hidden;
|
||||
}
|
||||
|
||||
function createFlow() {
|
||||
if (root.querySelector('.ux-generator-flow')) return root.querySelector('.ux-generator-flow');
|
||||
const commandbar = document.getElementById('generator-commandbar');
|
||||
if (!commandbar) return null;
|
||||
|
||||
const element = document.createElement('section');
|
||||
element.className = 'ux-generator-flow';
|
||||
element.setAttribute('aria-label', 'Geführter Artikelablauf');
|
||||
element.innerHTML = `
|
||||
<div class="ux-generator-flow-head">
|
||||
<div>
|
||||
<span class="ux-generator-flow-kicker">Geführter Ablauf</span>
|
||||
<h3>In drei klaren Schritten zum fertigen Artikel</h3>
|
||||
<p>Vendoo zeigt nur das, was du im aktuellen Schritt wirklich benötigst. Erweiterte Optionen bleiben jederzeit erreichbar.</p>
|
||||
</div>
|
||||
<div class="ux-generator-overall" aria-live="polite">
|
||||
<strong id="ux-generator-progress-label">Noch nicht begonnen</strong>
|
||||
<span id="ux-generator-progress-copy">Starte mit mindestens einem Produktfoto.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ux-generator-progress" aria-hidden="true"><span id="ux-generator-progress-bar"></span></div>
|
||||
<div class="ux-generator-stages" role="list">
|
||||
<button type="button" class="ux-generator-stage is-current" data-ux-generator-stage="photos" role="listitem">
|
||||
<span class="ux-generator-stage-number">1</span>
|
||||
<span><strong>Fotos hinzufügen</strong><small>Produkt gut erkennbar aufnehmen</small></span>
|
||||
<b class="ux-generator-stage-state" data-ux-stage-state="photos">Offen</b>
|
||||
</button>
|
||||
<button type="button" class="ux-generator-stage" data-ux-generator-stage="settings" role="listitem">
|
||||
<span class="ux-generator-stage-number">2</span>
|
||||
<span><strong>Ziel festlegen</strong><small>Plattform und optional Vorlage wählen</small></span>
|
||||
<b class="ux-generator-stage-state" data-ux-stage-state="settings">Danach</b>
|
||||
</button>
|
||||
<button type="button" class="ux-generator-stage" data-ux-generator-stage="result" role="listitem">
|
||||
<span class="ux-generator-stage-number">3</span>
|
||||
<span><strong>Prüfen & speichern</strong><small>AI-Ergebnis kontrollieren und ergänzen</small></span>
|
||||
<b class="ux-generator-stage-state" data-ux-stage-state="result">Danach</b>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ux-generator-next">
|
||||
<div class="ux-generator-next-copy">
|
||||
<span>Nächster sinnvoller Schritt</span>
|
||||
<strong id="ux-generator-next-title">Produktfoto hinzufügen</strong>
|
||||
<small id="ux-generator-next-copy">Ein Foto genügt zum Start; drei oder mehr Perspektiven verbessern die Erkennung.</small>
|
||||
</div>
|
||||
<button type="button" class="vd-button primary" id="ux-generator-next-button" data-ux-generator-action="focus-photos">Fotos hinzufügen</button>
|
||||
</div>`;
|
||||
commandbar.before(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function createAdvancedSettings() {
|
||||
const stack = root.querySelector('.generator-config-stack');
|
||||
if (!stack || stack.querySelector('.ux-generator-advanced')) return;
|
||||
|
||||
const platform = document.getElementById('platform-buttons')?.closest('.generator-config-card');
|
||||
const template = document.getElementById('template-select')?.closest('.generator-config-card');
|
||||
const advancedItems = [
|
||||
document.getElementById('provider-buttons')?.closest('.generator-config-card'),
|
||||
document.getElementById('model-select-wrap'),
|
||||
root.querySelector('.generator-config-pair'),
|
||||
root.querySelector('.generator-notes-card'),
|
||||
].filter(Boolean);
|
||||
|
||||
if (!advancedItems.length) return;
|
||||
|
||||
platform?.classList.add('ux-generator-essential');
|
||||
template?.classList.add('ux-generator-essential');
|
||||
|
||||
const details = document.createElement('details');
|
||||
details.className = 'ux-generator-advanced';
|
||||
details.open = localStorage.getItem(STORAGE.advanced) === 'true';
|
||||
details.innerHTML = `
|
||||
<summary>
|
||||
<span><strong>Weitere Einstellungen</strong><small>AI-Dienst, Modell, Sprache, Varianten und Artikelhinweise</small></span>
|
||||
<b id="ux-generator-advanced-summary">Standardwerte aktiv</b>
|
||||
</summary>
|
||||
<div class="ux-generator-advanced-content"></div>`;
|
||||
const content = details.querySelector('.ux-generator-advanced-content');
|
||||
advancedItems.forEach(item => content.append(item));
|
||||
details.addEventListener('toggle', () => localStorage.setItem(STORAGE.advanced, String(details.open)));
|
||||
|
||||
if (template) template.after(details);
|
||||
else stack.append(details);
|
||||
}
|
||||
|
||||
function wrapOptionalSection(element, options) {
|
||||
if (!element || element.closest('.ux-generator-optional')) return null;
|
||||
const details = document.createElement('details');
|
||||
details.className = `ux-generator-optional ${options.className || ''}`.trim();
|
||||
details.open = localStorage.getItem(options.storageKey) === 'true';
|
||||
const summary = document.createElement('summary');
|
||||
summary.innerHTML = `<span><strong>${options.title}</strong><small>${options.copy}</small></span><b>${options.badge || 'Optional'}</b>`;
|
||||
const content = document.createElement('div');
|
||||
content.className = 'ux-generator-optional-content';
|
||||
element.before(details);
|
||||
content.append(element);
|
||||
details.append(summary, content);
|
||||
details.addEventListener('toggle', () => localStorage.setItem(options.storageKey, String(details.open)));
|
||||
return details;
|
||||
}
|
||||
|
||||
function simplifyMediaPanel() {
|
||||
const imageStudio = document.getElementById('generator-ai-model-card');
|
||||
const photoHelp = root.querySelector('.generator-photo-quality');
|
||||
|
||||
const studioDetails = wrapOptionalSection(imageStudio, {
|
||||
title: 'Produktbild Studio',
|
||||
copy: 'Zusätzliche AI-Bildvarianten erzeugen',
|
||||
badge: 'Optional',
|
||||
className: 'ux-generator-image-studio',
|
||||
storageKey: STORAGE.imageStudio,
|
||||
});
|
||||
const helpDetails = wrapOptionalSection(photoHelp, {
|
||||
title: 'Tipps für bessere Fotos',
|
||||
copy: 'Qualitätscheck und empfohlene Perspektiven',
|
||||
badge: 'Hilfe',
|
||||
className: 'ux-generator-photo-help',
|
||||
storageKey: STORAGE.photoHelp,
|
||||
});
|
||||
|
||||
if (studioDetails && imageStudio) studioDetails.hidden = imageStudio.classList.contains('hidden');
|
||||
if (helpDetails) helpDetails.open = localStorage.getItem(STORAGE.photoHelp) === 'true';
|
||||
}
|
||||
|
||||
function simplifyResultPanel() {
|
||||
const htmlDetails = document.getElementById('html-preview-wrap');
|
||||
if (!htmlDetails) return;
|
||||
htmlDetails.open = localStorage.getItem(STORAGE.htmlPreview) === 'true';
|
||||
htmlDetails.addEventListener('toggle', () => localStorage.setItem(STORAGE.htmlPreview, String(htmlDetails.open)));
|
||||
const summary = htmlDetails.querySelector('summary');
|
||||
if (summary) summary.firstChild.textContent = 'Plattform- und HTML-Vorschau ';
|
||||
}
|
||||
|
||||
function selectedPlatform() {
|
||||
const container = document.getElementById('platform-buttons');
|
||||
return container?.querySelector('.active, [aria-pressed="true"], [data-selected="true"]') || null;
|
||||
}
|
||||
|
||||
function getPhotoCount() {
|
||||
const previewCount = document.getElementById('photo-preview')?.children.length || 0;
|
||||
const countLabel = document.getElementById('generator-photo-count')?.textContent || '';
|
||||
const parsed = Number.parseInt(countLabel.match(/\d+/)?.[0] || '0', 10);
|
||||
return Math.max(previewCount, Number.isFinite(parsed) ? parsed : 0);
|
||||
}
|
||||
|
||||
function getWorkflowState() {
|
||||
const photoCount = getPhotoCount();
|
||||
const platform = selectedPlatform();
|
||||
const loading = isVisible(document.getElementById('loading'));
|
||||
const result = isVisible(document.getElementById('result-section'));
|
||||
const generate = document.getElementById('generate-btn');
|
||||
const save = document.getElementById('save-btn');
|
||||
return {
|
||||
photoCount,
|
||||
hasPhotos: photoCount > 0,
|
||||
hasPlatform: !!platform,
|
||||
platformLabel: platform?.textContent?.trim() || '',
|
||||
loading,
|
||||
hasResult: result,
|
||||
canGenerate: !!generate && !generate.disabled,
|
||||
canSave: !!save && !save.disabled,
|
||||
};
|
||||
}
|
||||
|
||||
function stageStatus(state) {
|
||||
if (!state.hasPhotos) return { stage: 'photos', progress: 0 };
|
||||
if (!state.hasPlatform) return { stage: 'settings', progress: 34 };
|
||||
if (state.loading || !state.hasResult) return { stage: 'settings', progress: state.loading ? 58 : 50 };
|
||||
if (state.hasResult && !state.canSave) return { stage: 'result', progress: 78 };
|
||||
return { stage: 'result', progress: 100 };
|
||||
}
|
||||
|
||||
function updateOptionalVisibility() {
|
||||
const imageStudio = document.getElementById('generator-ai-model-card');
|
||||
const wrapper = root.querySelector('.ux-generator-image-studio');
|
||||
if (wrapper && imageStudio) wrapper.hidden = imageStudio.classList.contains('hidden');
|
||||
}
|
||||
|
||||
function updateAdvancedSummary(state) {
|
||||
const summary = document.getElementById('ux-generator-advanced-summary');
|
||||
if (!summary) return;
|
||||
const provider = document.getElementById('provider-buttons')?.querySelector('.active, [aria-pressed="true"]')?.textContent?.trim();
|
||||
const language = document.getElementById('language-select')?.selectedOptions?.[0]?.textContent?.trim();
|
||||
const variants = document.getElementById('variants-select')?.value || '1';
|
||||
const parts = [provider, language, `${variants} Variante${variants === '1' ? '' : 'n'}`].filter(Boolean);
|
||||
setText(summary, parts.length ? parts.join(' · ') : (state.hasPlatform ? 'Standardwerte aktiv' : 'Nach Plattformwahl verfügbar'));
|
||||
}
|
||||
|
||||
function updateWorkflow() {
|
||||
updateScheduled = false;
|
||||
if (!flow) flow = createFlow();
|
||||
if (!flow) return;
|
||||
|
||||
const state = getWorkflowState();
|
||||
const status = stageStatus(state);
|
||||
root.dataset.uxGeneratorStage = status.stage;
|
||||
|
||||
const bar = document.getElementById('ux-generator-progress-bar');
|
||||
if (bar) bar.style.width = `${status.progress}%`;
|
||||
|
||||
const stageButtons = [...flow.querySelectorAll('[data-ux-generator-stage]')];
|
||||
stageButtons.forEach(button => {
|
||||
const stage = button.dataset.uxGeneratorStage;
|
||||
const complete = stage === 'photos' ? state.hasPhotos : stage === 'settings' ? state.hasPhotos && state.hasPlatform : state.hasResult && state.canSave;
|
||||
const current = stage === status.stage;
|
||||
button.classList.toggle('is-complete', complete);
|
||||
button.classList.toggle('is-current', current);
|
||||
button.setAttribute('aria-current', current ? 'step' : 'false');
|
||||
const label = button.querySelector('[data-ux-stage-state]');
|
||||
setText(label, complete ? 'Fertig' : current ? 'Jetzt' : 'Danach');
|
||||
});
|
||||
|
||||
const progressLabel = document.getElementById('ux-generator-progress-label');
|
||||
const progressCopy = document.getElementById('ux-generator-progress-copy');
|
||||
const nextTitle = document.getElementById('ux-generator-next-title');
|
||||
const nextCopy = document.getElementById('ux-generator-next-copy');
|
||||
const nextButton = document.getElementById('ux-generator-next-button');
|
||||
|
||||
if (!state.hasPhotos) {
|
||||
setText(progressLabel, 'Schritt 1 von 3');
|
||||
setText(progressCopy, 'Noch keine Produktfotos vorhanden.');
|
||||
setText(nextTitle, 'Produktfoto hinzufügen');
|
||||
setText(nextCopy, 'Ein Foto genügt zum Start; drei oder mehr Perspektiven verbessern die Erkennung.');
|
||||
setText(nextButton, 'Fotos hinzufügen');
|
||||
if (nextButton) nextButton.dataset.uxGeneratorAction = 'focus-photos';
|
||||
} else if (!state.hasPlatform) {
|
||||
setText(progressLabel, 'Schritt 2 von 3');
|
||||
setText(progressCopy, `${state.photoCount} Foto${state.photoCount === 1 ? '' : 's'} bereit · Zielplattform fehlt.`);
|
||||
setText(nextTitle, 'Zielplattform auswählen');
|
||||
setText(nextCopy, 'Vendoo passt Titel, Beschreibung und Pflichtfelder an die gewählte Plattform an.');
|
||||
setText(nextButton, 'Plattform wählen');
|
||||
if (nextButton) nextButton.dataset.uxGeneratorAction = 'focus-settings';
|
||||
} else if (state.loading) {
|
||||
setText(progressLabel, 'AI-Analyse läuft');
|
||||
setText(progressCopy, `${state.platformLabel || 'Zielplattform'} · Produktdaten werden vorbereitet.`);
|
||||
setText(nextTitle, 'Analyse wird ausgeführt');
|
||||
setText(nextCopy, 'Du kannst währenddessen die Fotos und Einstellungen weiterhin prüfen.');
|
||||
setText(nextButton, 'Einstellungen anzeigen');
|
||||
if (nextButton) nextButton.dataset.uxGeneratorAction = 'focus-settings';
|
||||
} else if (!state.hasResult) {
|
||||
setText(progressLabel, 'Bereit zum Generieren');
|
||||
setText(progressCopy, `${state.photoCount} Foto${state.photoCount === 1 ? '' : 's'} · ${state.platformLabel || 'Plattform gewählt'}`);
|
||||
setText(nextTitle, 'Artikeltext erzeugen');
|
||||
setText(nextCopy, 'Die AI analysiert Produkt, Zustand und marktplatzrelevante Angaben.');
|
||||
setText(nextButton, state.canGenerate ? 'Artikel generieren' : 'Einstellungen prüfen');
|
||||
if (nextButton) nextButton.dataset.uxGeneratorAction = state.canGenerate ? 'generate' : 'focus-settings';
|
||||
} else if (!state.canSave) {
|
||||
setText(progressLabel, 'Schritt 3 von 3');
|
||||
setText(progressCopy, 'Das Ergebnis ist da und benötigt noch deine Prüfung.');
|
||||
setText(nextTitle, 'Vorschlag kontrollieren');
|
||||
setText(nextCopy, 'Prüfe Titel, Beschreibung, Eigenschaften und Preis, bevor du speicherst.');
|
||||
setText(nextButton, 'Ergebnis prüfen');
|
||||
if (nextButton) nextButton.dataset.uxGeneratorAction = 'focus-result';
|
||||
} else {
|
||||
setText(progressLabel, 'Artikel ist speicherbereit');
|
||||
setText(progressCopy, 'Alle drei Arbeitsschritte sind abgeschlossen.');
|
||||
setText(nextTitle, 'Abschließend speichern');
|
||||
setText(nextCopy, 'Kontrolliere die Angaben ein letztes Mal und nutze danach den Speichern-Button oben.');
|
||||
setText(nextButton, 'Zum Speichern');
|
||||
if (nextButton) nextButton.dataset.uxGeneratorAction = 'focus-save';
|
||||
}
|
||||
|
||||
updateAdvancedSummary(state);
|
||||
updateOptionalVisibility();
|
||||
}
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (updateScheduled) return;
|
||||
updateScheduled = true;
|
||||
window.requestAnimationFrame(updateWorkflow);
|
||||
}
|
||||
|
||||
function focusStage(stage) {
|
||||
const selector = stageTargets[stage];
|
||||
const panel = selector ? root.querySelector(selector) : null;
|
||||
if (!panel) return;
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
panel.classList.add('ux-generator-panel-focus');
|
||||
window.setTimeout(() => panel.classList.remove('ux-generator-panel-focus'), 900);
|
||||
|
||||
const focusTarget = stage === 'photos'
|
||||
? document.getElementById('dropzone')
|
||||
: stage === 'settings'
|
||||
? document.getElementById('platform-buttons')?.querySelector('button') || document.getElementById('template-select')
|
||||
: document.getElementById('result-title') || panel;
|
||||
if (focusTarget) {
|
||||
if (!focusTarget.hasAttribute('tabindex') && focusTarget === document.getElementById('dropzone')) focusTarget.setAttribute('tabindex', '0');
|
||||
window.setTimeout(() => focusTarget.focus({ preventScroll: true }), 260);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWorkflowAction(action) {
|
||||
if (action === 'generate') {
|
||||
const generate = document.getElementById('generate-btn');
|
||||
if (generate && !generate.disabled) generate.click();
|
||||
else focusStage('settings');
|
||||
return;
|
||||
}
|
||||
if (action === 'focus-save') {
|
||||
const save = document.getElementById('save-btn');
|
||||
save?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
window.setTimeout(() => save?.focus({ preventScroll: true }), 260);
|
||||
return;
|
||||
}
|
||||
focusStage(action.replace('focus-', ''));
|
||||
}
|
||||
|
||||
function bindWorkflow() {
|
||||
root.addEventListener('click', event => {
|
||||
const stage = event.target.closest?.('[data-ux-generator-stage]');
|
||||
if (stage) {
|
||||
focusStage(stage.dataset.uxGeneratorStage);
|
||||
return;
|
||||
}
|
||||
const action = event.target.closest?.('[data-ux-generator-action]');
|
||||
if (action) handleWorkflowAction(action.dataset.uxGeneratorAction);
|
||||
scheduleUpdate();
|
||||
});
|
||||
root.addEventListener('change', scheduleUpdate);
|
||||
root.addEventListener('input', scheduleUpdate);
|
||||
document.getElementById('reset-generator-btn')?.addEventListener('click', () => window.setTimeout(scheduleUpdate, 0));
|
||||
}
|
||||
|
||||
function observeWorkflowInputs() {
|
||||
const targets = [
|
||||
document.getElementById('photo-preview'),
|
||||
document.getElementById('generator-photo-count'),
|
||||
document.getElementById('generator-cover'),
|
||||
document.getElementById('platform-buttons'),
|
||||
document.getElementById('loading'),
|
||||
document.getElementById('result-section'),
|
||||
document.getElementById('generate-btn'),
|
||||
document.getElementById('save-btn'),
|
||||
document.getElementById('generator-ai-model-card'),
|
||||
].filter(Boolean);
|
||||
|
||||
const observer = new MutationObserver(scheduleUpdate);
|
||||
targets.forEach(target => observer.observe(target, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'disabled', 'aria-pressed', 'data-selected'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
}));
|
||||
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
|
||||
}
|
||||
|
||||
flow = createFlow();
|
||||
createAdvancedSettings();
|
||||
simplifyMediaPanel();
|
||||
simplifyResultPanel();
|
||||
bindWorkflow();
|
||||
observeWorkflowInputs();
|
||||
updateWorkflow();
|
||||
})();
|
||||
@@ -0,0 +1,373 @@
|
||||
.ux-nav-history {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-inline: 4px 8px;
|
||||
}
|
||||
|
||||
.ux-nav-history-button {
|
||||
align-items: center;
|
||||
background: var(--vd-surface, var(--surface, #fff));
|
||||
block-size: 34px;
|
||||
border: 1px solid var(--vd-border-subtle, var(--border-color, #d9d3cc));
|
||||
border-radius: 10px;
|
||||
color: var(--vd-text, var(--text-color, #26221f));
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: 600 18px/1 system-ui;
|
||||
inline-size: 34px;
|
||||
justify-content: center;
|
||||
transition: transform .16s ease, border-color .16s ease, background .16s ease;
|
||||
}
|
||||
|
||||
.ux-nav-history-button:hover:not(:disabled) {
|
||||
background: var(--vd-surface-raised, var(--surface-hover, #faf7f4));
|
||||
border-color: var(--vd-accent, var(--primary, #d95c34));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ux-nav-history-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .38;
|
||||
}
|
||||
|
||||
.ux-global-search {
|
||||
backdrop-filter: blur(8px);
|
||||
background: color-mix(in srgb, #15110f 55%, transparent);
|
||||
display: grid;
|
||||
inset: 0;
|
||||
padding: clamp(70px, 12vh, 140px) 18px 18px;
|
||||
place-items: start center;
|
||||
position: fixed;
|
||||
z-index: 5000;
|
||||
}
|
||||
|
||||
.ux-global-search.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-global-search-panel {
|
||||
background: var(--vd-surface, var(--surface, #fff));
|
||||
border: 1px solid var(--vd-border-subtle, var(--border-color, #ded7d1));
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 28px 90px rgba(30, 20, 15, .25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
inline-size: min(760px, 100%);
|
||||
max-block-size: min(720px, calc(100vh - 100px));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ux-global-search-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 10px;
|
||||
}
|
||||
|
||||
.ux-global-search-heading {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ux-global-search-header h2,
|
||||
.ux-global-search-header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ux-global-search-header h2 {
|
||||
color: var(--vd-text, var(--text-color, #26221f));
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ux-global-search-header p {
|
||||
color: var(--vd-text-muted, var(--muted, #716a64));
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.ux-global-search-close {
|
||||
background: transparent;
|
||||
block-size: 34px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
inline-size: 34px;
|
||||
}
|
||||
|
||||
.ux-global-search-close:hover {
|
||||
background: var(--vd-surface-raised, var(--surface-hover, #faf8f6));
|
||||
}
|
||||
|
||||
.ux-global-search-input-wrap {
|
||||
align-items: center;
|
||||
background: var(--vd-surface-raised, var(--surface-hover, #faf8f6));
|
||||
border: 1px solid var(--vd-border-strong, var(--border-color, #cfc7c0));
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 0 16px 8px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.ux-global-search-input-wrap:focus-within {
|
||||
border-color: var(--vd-accent, var(--primary, #d95c34));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--vd-accent, var(--primary, #d95c34)) 16%, transparent);
|
||||
}
|
||||
|
||||
.ux-global-search-input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
min-inline-size: 0;
|
||||
outline: 0;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.ux-global-search-input-wrap kbd,
|
||||
.ux-global-search-footer,
|
||||
.ux-global-search-status {
|
||||
color: var(--vd-text-muted, var(--muted, #716a64));
|
||||
font-size: .78rem;
|
||||
}
|
||||
|
||||
.ux-global-search-input-wrap kbd,
|
||||
.ux-global-search-result-meta kbd {
|
||||
background: var(--vd-surface, var(--surface, #fff));
|
||||
border: 1px solid var(--vd-border-subtle, var(--border-color, #ded7d1));
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 0 color-mix(in srgb, var(--vd-border-strong, #cfc7c0) 60%, transparent);
|
||||
font-family: inherit;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.ux-global-search-status {
|
||||
min-block-size: 22px;
|
||||
padding: 0 20px 4px;
|
||||
}
|
||||
|
||||
.ux-global-search-results {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 2px 10px 12px;
|
||||
scroll-padding-block: 34px 10px;
|
||||
}
|
||||
|
||||
.ux-global-search-section {
|
||||
color: var(--vd-text-muted, var(--muted, #716a64));
|
||||
font-size: .7rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: .06em;
|
||||
padding: 12px 12px 5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ux-global-search-result {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
inline-size: 100%;
|
||||
padding: 11px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ux-global-search-result:hover,
|
||||
.ux-global-search-result[aria-selected="true"] {
|
||||
background: var(--vd-accent-soft, var(--primary-soft, #f7e4dc));
|
||||
}
|
||||
|
||||
.ux-global-search-result.is-disabled,
|
||||
.ux-global-search-result[aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
opacity: .62;
|
||||
}
|
||||
|
||||
.ux-global-search-result.is-disabled[aria-selected="true"] {
|
||||
background: var(--vd-surface-raised, var(--surface-hover, #faf8f6));
|
||||
box-shadow: inset 3px 0 0 var(--vd-warning, #b66a00);
|
||||
}
|
||||
|
||||
.ux-global-search-result-icon {
|
||||
align-items: center;
|
||||
background: var(--vd-surface-raised, var(--surface-hover, #faf8f6));
|
||||
block-size: 34px;
|
||||
border: 1px solid var(--vd-border-subtle, var(--border-color, #ded7d1));
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
font-size: 1rem;
|
||||
inline-size: 34px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ux-global-search-result-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.ux-global-search-result-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.ux-global-search-result-copy strong {
|
||||
font-size: .93rem;
|
||||
}
|
||||
|
||||
.ux-global-search-result-copy small,
|
||||
.ux-global-search-result-action,
|
||||
.ux-global-search-result-group {
|
||||
color: var(--vd-text-muted, var(--muted, #716a64));
|
||||
font-size: .78rem;
|
||||
}
|
||||
|
||||
.ux-global-search-result-copy small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ux-global-search-badge {
|
||||
background: color-mix(in srgb, var(--vd-accent, var(--primary, #d95c34)) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--vd-accent, var(--primary, #d95c34)) 24%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--vd-accent, var(--primary, #d95c34));
|
||||
font-size: .66rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.ux-global-search-result-meta {
|
||||
align-items: flex-end;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
justify-items: end;
|
||||
min-inline-size: 92px;
|
||||
}
|
||||
|
||||
.ux-global-search-empty {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 30px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ux-global-search-empty span {
|
||||
color: var(--vd-text-muted, var(--muted, #716a64));
|
||||
font-size: .86rem;
|
||||
}
|
||||
|
||||
.ux-global-search-footer {
|
||||
border-top: 1px solid var(--vd-border-subtle, var(--border-color, #ded7d1));
|
||||
padding: 12px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ux-search-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ux-nav-history-button:focus-visible,
|
||||
.ux-global-search-close:focus-visible,
|
||||
.ux-global-search-result:focus-visible,
|
||||
.ux-global-search-input:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--vd-accent, var(--primary, #d95c34)) 36%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media(max-width:900px) {
|
||||
.ux-nav-history {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.topbar-context-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-nav-history-button {
|
||||
block-size: 32px;
|
||||
inline-size: 32px;
|
||||
}
|
||||
|
||||
.ux-global-search {
|
||||
padding-top: 72px;
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:620px) {
|
||||
.ux-nav-history {
|
||||
margin-inline: 0 4px;
|
||||
}
|
||||
|
||||
.ux-nav-history-button {
|
||||
block-size: 30px;
|
||||
border-radius: 8px;
|
||||
inline-size: 30px;
|
||||
}
|
||||
|
||||
.ux-global-search {
|
||||
padding: 10px;
|
||||
place-items: end center;
|
||||
}
|
||||
|
||||
.ux-global-search-panel {
|
||||
border-radius: 16px;
|
||||
max-block-size: 88vh;
|
||||
}
|
||||
|
||||
.ux-global-search-header {
|
||||
padding: 15px 16px 8px;
|
||||
}
|
||||
|
||||
.ux-global-search-header p,
|
||||
.ux-global-search-result-group,
|
||||
.ux-global-search-result-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-global-search-result {
|
||||
gap: 9px;
|
||||
grid-template-columns: 32px minmax(0, 1fr) auto;
|
||||
padding-inline: 9px;
|
||||
}
|
||||
|
||||
.ux-global-search-result-icon {
|
||||
block-size: 30px;
|
||||
border-radius: 8px;
|
||||
inline-size: 30px;
|
||||
}
|
||||
|
||||
.ux-global-search-result-meta {
|
||||
min-inline-size: auto;
|
||||
}
|
||||
|
||||
.ux-global-search-footer {
|
||||
font-size: .72rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
.ux-nav-history-button {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.ux-global-search {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.ux-global-search-result {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
if (document.documentElement.dataset.uxGlobalNavigationReady === '1') return;
|
||||
document.documentElement.dataset.uxGlobalNavigationReady = '1';
|
||||
|
||||
const state = {
|
||||
activeView: null,
|
||||
restoring: false,
|
||||
query: '',
|
||||
selectedIndex: 0,
|
||||
commands: [],
|
||||
previousFocus: null,
|
||||
unsubscribeCommands: null,
|
||||
};
|
||||
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
function getActiveView() {
|
||||
return document.querySelector('.sidebar-nav .nav-item.active[data-tab]')?.dataset.tab
|
||||
|| document.querySelector('.tab-content.active')?.id
|
||||
|| 'dashboard';
|
||||
}
|
||||
|
||||
function getNavItem(view) {
|
||||
return document.querySelector(`.sidebar-nav .nav-item[data-tab="${CSS.escape(view)}"]`);
|
||||
}
|
||||
|
||||
function activateView(view, { replace = false } = {}) {
|
||||
const item = getNavItem(view);
|
||||
if (!item || item.classList.contains('hidden') || item.getAttribute('aria-hidden') === 'true') return false;
|
||||
state.restoring = true;
|
||||
item.closest('details.ux-nav-section')?.setAttribute('open', '');
|
||||
item.click();
|
||||
requestAnimationFrame(() => {
|
||||
state.activeView = getActiveView();
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = `view=${encodeURIComponent(state.activeView)}`;
|
||||
const method = replace ? 'replaceState' : 'pushState';
|
||||
history[method]({ vendooView: state.activeView }, '', url);
|
||||
state.restoring = false;
|
||||
updateHistoryButtons();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureInitialHistoryState() {
|
||||
const hashView = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('view');
|
||||
const initialItem = hashView ? getNavItem(hashView) : null;
|
||||
const initial = initialItem && !initialItem.classList.contains('hidden') && initialItem.getAttribute('aria-hidden') !== 'true' ? hashView : getActiveView();
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = `view=${encodeURIComponent(initial)}`;
|
||||
history.replaceState({ vendooView: initial }, '', url);
|
||||
state.activeView = initial;
|
||||
if (initial !== getActiveView()) activateView(initial, { replace: true });
|
||||
}
|
||||
|
||||
function updateHistoryButtons() {
|
||||
const back = document.getElementById('ux-nav-back');
|
||||
const forward = document.getElementById('ux-nav-forward');
|
||||
if (back) back.disabled = history.length <= 1;
|
||||
if (forward) forward.disabled = false;
|
||||
}
|
||||
|
||||
function createHistoryControls() {
|
||||
const context = document.querySelector('.topbar-context');
|
||||
if (!context || document.getElementById('ux-nav-history')) return;
|
||||
const controls = el('div', 'ux-nav-history');
|
||||
controls.id = 'ux-nav-history';
|
||||
controls.setAttribute('aria-label', 'Navigationsverlauf');
|
||||
const back = el('button', 'ux-nav-history-button');
|
||||
back.id = 'ux-nav-back';
|
||||
back.type = 'button';
|
||||
back.title = 'Zurück (Alt + Pfeil links)';
|
||||
back.setAttribute('aria-label', 'Zurück');
|
||||
back.textContent = '←';
|
||||
const forward = el('button', 'ux-nav-history-button');
|
||||
forward.id = 'ux-nav-forward';
|
||||
forward.type = 'button';
|
||||
forward.title = 'Vorwärts (Alt + Pfeil rechts)';
|
||||
forward.setAttribute('aria-label', 'Vorwärts');
|
||||
forward.textContent = '→';
|
||||
back.addEventListener('click', () => history.back());
|
||||
forward.addEventListener('click', () => history.forward());
|
||||
controls.append(back, forward);
|
||||
context.querySelector('.topbar-heading')?.before(controls);
|
||||
updateHistoryButtons();
|
||||
}
|
||||
|
||||
function getRegistry() {
|
||||
const registry = window.VendooCommands;
|
||||
return registry && typeof registry.list === 'function' && typeof registry.execute === 'function' ? registry : null;
|
||||
}
|
||||
|
||||
function getPaletteCommands(query = '') {
|
||||
const registry = getRegistry();
|
||||
if (!registry) return [];
|
||||
return registry.list({
|
||||
query,
|
||||
includeDisabled: true,
|
||||
includeHidden: false,
|
||||
context: { source: 'command-palette' },
|
||||
});
|
||||
}
|
||||
|
||||
function createDialog() {
|
||||
if (document.getElementById('ux-global-search')) return document.getElementById('ux-global-search');
|
||||
const dialog = el('div', 'ux-global-search hidden');
|
||||
dialog.id = 'ux-global-search';
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
dialog.setAttribute('aria-labelledby', 'ux-global-search-title');
|
||||
dialog.setAttribute('aria-describedby', 'ux-global-search-help');
|
||||
|
||||
const panel = el('section', 'ux-global-search-panel');
|
||||
const header = el('header', 'ux-global-search-header');
|
||||
const heading = el('div', 'ux-global-search-heading');
|
||||
const title = el('h2', '', 'Suchen und öffnen');
|
||||
title.id = 'ux-global-search-title';
|
||||
const subtitle = el('p', '', 'Bereiche und registrierte Befehle sicher finden.');
|
||||
heading.append(title, subtitle);
|
||||
const close = el('button', 'ux-global-search-close', '×');
|
||||
close.type = 'button';
|
||||
close.setAttribute('aria-label', 'Suche schließen');
|
||||
|
||||
const inputWrap = el('div', 'ux-global-search-input-wrap');
|
||||
const input = el('input', 'ux-global-search-input');
|
||||
input.id = 'ux-global-search-input';
|
||||
input.type = 'search';
|
||||
input.placeholder = 'Bereich, Befehl oder Stichwort suchen …';
|
||||
input.autocomplete = 'off';
|
||||
input.spellcheck = false;
|
||||
input.setAttribute('role', 'combobox');
|
||||
input.setAttribute('aria-autocomplete', 'list');
|
||||
input.setAttribute('aria-controls', 'ux-global-search-results');
|
||||
input.setAttribute('aria-expanded', 'false');
|
||||
const shortcut = el('kbd', '', 'Esc');
|
||||
inputWrap.append(input, shortcut);
|
||||
|
||||
const status = el('div', 'ux-global-search-status');
|
||||
status.id = 'ux-global-search-status';
|
||||
status.setAttribute('role', 'status');
|
||||
status.setAttribute('aria-live', 'polite');
|
||||
status.setAttribute('aria-atomic', 'true');
|
||||
|
||||
const results = el('div', 'ux-global-search-results');
|
||||
results.id = 'ux-global-search-results';
|
||||
results.setAttribute('role', 'listbox');
|
||||
results.setAttribute('aria-label', 'Verfügbare Befehle');
|
||||
|
||||
const footer = el('footer', 'ux-global-search-footer');
|
||||
const help = el('span', '', '↑↓ auswählen · Enter bestätigen · Esc schließen');
|
||||
help.id = 'ux-global-search-help';
|
||||
footer.append(help);
|
||||
|
||||
header.append(heading, close);
|
||||
panel.append(header, inputWrap, status, results, footer);
|
||||
dialog.append(panel);
|
||||
document.body.append(dialog);
|
||||
|
||||
const isOpen = () => !dialog.classList.contains('hidden');
|
||||
|
||||
const updateSelection = ({ announce = false } = {}) => {
|
||||
const options = [...results.querySelectorAll('[data-command-id]')];
|
||||
if (!options.length) {
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
return;
|
||||
}
|
||||
state.selectedIndex = Math.max(0, Math.min(state.selectedIndex, options.length - 1));
|
||||
options.forEach((option, index) => option.setAttribute('aria-selected', index === state.selectedIndex ? 'true' : 'false'));
|
||||
const selected = options[state.selectedIndex];
|
||||
input.setAttribute('aria-activedescendant', selected.id);
|
||||
selected.scrollIntoView({ block: 'nearest' });
|
||||
if (announce) {
|
||||
const command = state.commands[state.selectedIndex];
|
||||
status.textContent = command?.enabled
|
||||
? `${command.title} ausgewählt.`
|
||||
: `${command?.title || 'Befehl'} nicht verfügbar: ${command?.disabledReason || 'Der Befehl ist deaktiviert.'}`;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = ({ restoreFocus = true } = {}) => {
|
||||
if (!isOpen()) return;
|
||||
dialog.classList.add('hidden');
|
||||
dialog.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('ux-search-open');
|
||||
state.query = '';
|
||||
state.selectedIndex = 0;
|
||||
state.commands = [];
|
||||
input.value = '';
|
||||
input.setAttribute('aria-expanded', 'false');
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
results.replaceChildren();
|
||||
status.textContent = '';
|
||||
if (restoreFocus && state.previousFocus instanceof HTMLElement && state.previousFocus.isConnected) {
|
||||
state.previousFocus.focus({ preventScroll: true });
|
||||
}
|
||||
state.previousFocus = null;
|
||||
};
|
||||
|
||||
const executeCommand = commandId => {
|
||||
const registry = getRegistry();
|
||||
const command = state.commands.find(item => item.id === commandId);
|
||||
if (!registry || !command) return;
|
||||
if (!command.enabled) {
|
||||
status.textContent = command.disabledReason || 'Dieser Befehl ist derzeit nicht verfügbar.';
|
||||
return;
|
||||
}
|
||||
const executed = registry.execute(command.id, { source: 'command-palette' });
|
||||
if (executed) closeDialog();
|
||||
else status.textContent = command.disabledReason || 'Der Befehl konnte nicht ausgeführt werden.';
|
||||
};
|
||||
|
||||
const executeSelected = () => {
|
||||
const command = state.commands[state.selectedIndex];
|
||||
if (command) executeCommand(command.id);
|
||||
};
|
||||
|
||||
const appendSectionHeading = label => {
|
||||
const headingNode = el('div', 'ux-global-search-section', label);
|
||||
headingNode.setAttribute('role', 'presentation');
|
||||
results.append(headingNode);
|
||||
};
|
||||
|
||||
const appendCommand = (command, index) => {
|
||||
const button = el('button', 'ux-global-search-result');
|
||||
button.type = 'button';
|
||||
button.id = `ux-global-search-option-${index}`;
|
||||
button.dataset.commandId = command.id;
|
||||
button.setAttribute('role', 'option');
|
||||
button.setAttribute('aria-selected', index === state.selectedIndex ? 'true' : 'false');
|
||||
button.setAttribute('aria-disabled', command.enabled ? 'false' : 'true');
|
||||
if (!command.enabled) button.classList.add('is-disabled');
|
||||
|
||||
const icon = el('span', 'ux-global-search-result-icon', command.icon || '•');
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
const copy = el('span', 'ux-global-search-result-copy');
|
||||
const titleRow = el('span', 'ux-global-search-result-title');
|
||||
titleRow.append(el('strong', '', command.title));
|
||||
if (command.favorite) titleRow.append(el('span', 'ux-global-search-badge', 'Favorit'));
|
||||
else if (command.recent) titleRow.append(el('span', 'ux-global-search-badge', 'Zuletzt'));
|
||||
const description = el('small', '', command.enabled
|
||||
? (command.description || command.group)
|
||||
: (command.disabledReason || 'Der Befehl ist derzeit nicht verfügbar.'));
|
||||
copy.append(titleRow, description);
|
||||
|
||||
const meta = el('span', 'ux-global-search-result-meta');
|
||||
meta.append(el('span', 'ux-global-search-result-group', command.group));
|
||||
if (command.shortcut) meta.append(el('kbd', '', command.shortcut));
|
||||
else meta.append(el('span', 'ux-global-search-result-action', command.enabled ? 'Öffnen' : 'Nicht verfügbar'));
|
||||
button.append(icon, copy, meta);
|
||||
button.addEventListener('pointermove', () => {
|
||||
if (state.selectedIndex === index) return;
|
||||
state.selectedIndex = index;
|
||||
updateSelection();
|
||||
});
|
||||
button.addEventListener('click', () => executeCommand(command.id));
|
||||
results.append(button);
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const query = input.value.trim();
|
||||
state.query = query;
|
||||
state.commands = getPaletteCommands(query);
|
||||
state.selectedIndex = Math.min(state.selectedIndex, Math.max(0, state.commands.length - 1));
|
||||
results.replaceChildren();
|
||||
|
||||
if (!state.commands.length) {
|
||||
const empty = el('div', 'ux-global-search-empty');
|
||||
empty.append(
|
||||
el('strong', '', 'Kein passender Befehl gefunden'),
|
||||
el('span', '', 'Versuche Titel, Bereich, Beschreibung oder ein anderes Stichwort. Produktive Aktionen werden niemals automatisch ausgeführt.'),
|
||||
);
|
||||
results.append(empty);
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
status.textContent = 'Keine passenden Befehle.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
let lastSection = '';
|
||||
state.commands.forEach((command, index) => {
|
||||
const section = command.favorite ? 'Favoriten' : command.recent ? 'Zuletzt geöffnet' : 'Alle Bereiche';
|
||||
if (section !== lastSection) {
|
||||
appendSectionHeading(section);
|
||||
lastSection = section;
|
||||
}
|
||||
appendCommand(command, index);
|
||||
});
|
||||
} else {
|
||||
state.commands.forEach(appendCommand);
|
||||
}
|
||||
status.textContent = `${state.commands.length} ${state.commands.length === 1 ? 'Befehl' : 'Befehle'} verfügbar.`;
|
||||
updateSelection();
|
||||
};
|
||||
|
||||
const openDialog = trigger => {
|
||||
if (isOpen()) return;
|
||||
state.previousFocus = trigger instanceof HTMLElement ? trigger : document.activeElement;
|
||||
dialog.classList.remove('hidden');
|
||||
dialog.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('ux-search-open');
|
||||
input.setAttribute('aria-expanded', 'true');
|
||||
state.selectedIndex = 0;
|
||||
render();
|
||||
requestAnimationFrame(() => input.focus({ preventScroll: true }));
|
||||
};
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
state.selectedIndex = 0;
|
||||
render();
|
||||
});
|
||||
input.addEventListener('keydown', event => {
|
||||
const count = state.commands.length;
|
||||
if (event.key === 'ArrowDown' && count) {
|
||||
event.preventDefault();
|
||||
state.selectedIndex = (state.selectedIndex + 1) % count;
|
||||
updateSelection({ announce: true });
|
||||
}
|
||||
if (event.key === 'ArrowUp' && count) {
|
||||
event.preventDefault();
|
||||
state.selectedIndex = (state.selectedIndex - 1 + count) % count;
|
||||
updateSelection({ announce: true });
|
||||
}
|
||||
if (event.key === 'Home' && count) {
|
||||
event.preventDefault();
|
||||
state.selectedIndex = 0;
|
||||
updateSelection({ announce: true });
|
||||
}
|
||||
if (event.key === 'End' && count) {
|
||||
event.preventDefault();
|
||||
state.selectedIndex = count - 1;
|
||||
updateSelection({ announce: true });
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
executeSelected();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
close.addEventListener('click', () => closeDialog());
|
||||
dialog.addEventListener('mousedown', event => {
|
||||
if (event.target === dialog) closeDialog();
|
||||
});
|
||||
dialog.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = [...panel.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')]
|
||||
.filter(node => !node.hidden && node.getClientRects().length > 0);
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
dialog._open = openDialog;
|
||||
dialog._close = closeDialog;
|
||||
dialog._render = render;
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function isEditableTarget(target) {
|
||||
return target instanceof Element && Boolean(target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]'));
|
||||
}
|
||||
|
||||
function bindSearch() {
|
||||
const dialog = createDialog();
|
||||
const trigger = document.getElementById('topbar-search-btn');
|
||||
trigger?.addEventListener('click', () => dialog._open(trigger));
|
||||
|
||||
const registry = getRegistry();
|
||||
if (registry?.subscribe) {
|
||||
state.unsubscribeCommands = registry.subscribe(() => {
|
||||
if (!dialog.classList.contains('hidden')) dialog._render();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
const modifier = /Mac|iPhone|iPad/.test(navigator.platform) ? event.metaKey : event.ctrlKey;
|
||||
const paletteOpen = !dialog.classList.contains('hidden');
|
||||
if (modifier && event.key.toLocaleLowerCase('de-DE') === 'k') {
|
||||
if (isEditableTarget(event.target) && !paletteOpen) return;
|
||||
event.preventDefault();
|
||||
paletteOpen ? dialog._close() : dialog._open(event.target);
|
||||
}
|
||||
if (event.altKey && event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
history.back();
|
||||
}
|
||||
if (event.altKey && event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
history.forward();
|
||||
}
|
||||
if (event.key === 'Escape' && paletteOpen) {
|
||||
event.preventDefault();
|
||||
dialog._close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function observeNavigation() {
|
||||
const nav = document.querySelector('.sidebar-nav');
|
||||
if (!nav) return;
|
||||
const observer = new MutationObserver(() => {
|
||||
const next = getActiveView();
|
||||
if (!next || next === state.activeView || state.restoring) return;
|
||||
state.activeView = next;
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = `view=${encodeURIComponent(next)}`;
|
||||
history.pushState({ vendooView: next }, '', url);
|
||||
updateHistoryButtons();
|
||||
});
|
||||
nav.querySelectorAll('.nav-item[data-tab]').forEach(item => observer.observe(item, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'aria-hidden'],
|
||||
}));
|
||||
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', event => {
|
||||
const view = event.state?.vendooView || new URLSearchParams(window.location.hash.replace(/^#/, '')).get('view');
|
||||
const item = view ? getNavItem(view) : null;
|
||||
if (!item || item.classList.contains('hidden') || item.getAttribute('aria-hidden') === 'true') return;
|
||||
state.restoring = true;
|
||||
item.click();
|
||||
requestAnimationFrame(() => {
|
||||
state.activeView = getActiveView();
|
||||
state.restoring = false;
|
||||
updateHistoryButtons();
|
||||
});
|
||||
});
|
||||
|
||||
function initialize() {
|
||||
createHistoryControls();
|
||||
ensureInitialHistoryState();
|
||||
bindSearch();
|
||||
observeNavigation();
|
||||
window.addEventListener('beforeunload', () => state.unsubscribeCommands?.(), { once: true });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initialize, { once: true });
|
||||
else initialize();
|
||||
})();
|
||||
@@ -0,0 +1,523 @@
|
||||
#image-factory.ux-image-factory-ready {
|
||||
--ux-factory-border: color-mix(in srgb, var(--vd-border, var(--border-color, #d9d7d2)) 82%, transparent);
|
||||
--ux-factory-soft: color-mix(in srgb, var(--vd-surface, var(--card-bg, #fff)) 88%, var(--vd-accent, var(--accent, #db4b2f)) 12%);
|
||||
--ux-factory-muted: var(--vd-text-muted, var(--text-muted, #6f6b65));
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-workflow {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: clamp(18px, 2.4vw, 28px);
|
||||
border: 1px solid var(--ux-factory-border);
|
||||
border-radius: var(--vd-radius-xl, 20px);
|
||||
background:
|
||||
radial-gradient(circle at 100% 0%, color-mix(in srgb, var(--vd-accent, var(--accent, #db4b2f)) 14%, transparent), transparent 42%),
|
||||
var(--vd-surface, var(--card-bg, #fff));
|
||||
box-shadow: var(--vd-shadow-sm, 0 8px 28px rgba(25, 22, 18, .06));
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-workflow-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-workflow-head > div {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-kicker {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--vd-accent, var(--accent, #db4b2f));
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-workflow h3,
|
||||
#image-factory .ux-image-factory-stage h3 {
|
||||
margin: 0;
|
||||
color: var(--vd-text, var(--text-color, #292621));
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-workflow p,
|
||||
#image-factory .ux-image-factory-stage p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--ux-factory-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-next {
|
||||
min-width: 210px;
|
||||
min-height: 42px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-areas: "index copy" "state state";
|
||||
align-items: center;
|
||||
gap: 8px 11px;
|
||||
min-width: 0;
|
||||
padding: 13px;
|
||||
border: 1px solid var(--ux-factory-border);
|
||||
border-radius: var(--vd-radius-lg, 14px);
|
||||
background: color-mix(in srgb, var(--vd-surface, var(--card-bg, #fff)) 94%, transparent);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color .18s ease, background .18s ease, transform .18s ease;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step:hover {
|
||||
border-color: color-mix(in srgb, var(--vd-accent, var(--accent, #db4b2f)) 48%, var(--ux-factory-border));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step-index {
|
||||
grid-area: index;
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--vd-surface-subtle, var(--muted-bg, #f2f0ec));
|
||||
color: var(--ux-factory-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step-copy {
|
||||
grid-area: copy;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step-copy strong,
|
||||
#image-factory .ux-image-factory-step-copy small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step-copy strong {
|
||||
overflow: hidden;
|
||||
color: var(--vd-text, var(--text-color, #292621));
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step-copy small {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
color: var(--ux-factory-muted);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step-state {
|
||||
grid-area: state;
|
||||
justify-self: start;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--vd-surface-subtle, var(--muted-bg, #f2f0ec));
|
||||
color: var(--ux-factory-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step.is-current {
|
||||
border-color: color-mix(in srgb, var(--vd-accent, var(--accent, #db4b2f)) 62%, var(--ux-factory-border));
|
||||
background: var(--ux-factory-soft);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step.is-current .ux-image-factory-step-index,
|
||||
#image-factory .ux-image-factory-step.is-current .ux-image-factory-step-state {
|
||||
background: var(--vd-accent, var(--accent, #db4b2f));
|
||||
color: var(--vd-on-accent, #fff);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step.is-complete .ux-image-factory-step-index {
|
||||
background: var(--vd-success, #2d8a61);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step.is-complete .ux-image-factory-step-state {
|
||||
background: color-mix(in srgb, var(--vd-success, #2d8a61) 15%, transparent);
|
||||
color: var(--vd-success, #2d8a61);
|
||||
}
|
||||
|
||||
#image-factory .image-factory-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#image-factory .image-factory-config {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-stage {
|
||||
scroll-margin-top: 92px;
|
||||
padding: clamp(17px, 2.2vw, 24px);
|
||||
border: 1px solid var(--ux-factory-border);
|
||||
border-radius: var(--vd-radius-xl, 18px);
|
||||
background: var(--vd-surface, var(--card-bg, #fff));
|
||||
box-shadow: var(--vd-shadow-xs, 0 4px 18px rgba(25, 22, 18, .045));
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-stage-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 13px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-stage-index {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: var(--vd-surface-subtle, var(--muted-bg, #f2f0ec));
|
||||
color: var(--vd-accent, var(--accent, #db4b2f));
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
|
||||
#image-factory[data-ux-factory-current-step="sources"] .ux-image-factory-stage-sources,
|
||||
#image-factory[data-ux-factory-current-step="profiles"] .ux-image-factory-stage-profiles,
|
||||
#image-factory[data-ux-factory-current-step="review"] .ux-image-factory-stage-review {
|
||||
border-color: color-mix(in srgb, var(--vd-accent, var(--accent, #db4b2f)) 48%, var(--ux-factory-border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--vd-accent, var(--accent, #db4b2f)) 8%, transparent);
|
||||
}
|
||||
|
||||
#image-factory .image-factory-source-head,
|
||||
#image-factory .image-factory-profile-head {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
#image-factory .image-factory-source-head .image-factory-source-tools:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#image-factory #image-factory-source-search {
|
||||
width: min(520px, 100%);
|
||||
min-height: 42px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-source-controls {
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid var(--ux-factory-border);
|
||||
border-radius: var(--vd-radius-md, 12px);
|
||||
background: var(--vd-surface-subtle, var(--muted-bg, #f7f5f2));
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-source-controls > summary,
|
||||
#image-factory .ux-image-factory-monitor > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-source-controls > summary::-webkit-details-marker,
|
||||
#image-factory .ux-image-factory-monitor > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-source-controls > summary {
|
||||
padding: 10px 13px;
|
||||
color: var(--vd-text, var(--text-color, #292621));
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-source-controls > summary::after {
|
||||
content: "+";
|
||||
float: right;
|
||||
color: var(--ux-factory-muted);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-source-controls[open] > summary::after {
|
||||
content: "−";
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-source-controls-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-source-controls-body > * {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
#image-factory .image-factory-source-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(142px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: min(55vh, 560px);
|
||||
overflow: auto;
|
||||
padding: 3px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
#image-factory .image-factory-source-grid > * {
|
||||
min-width: 0;
|
||||
border-radius: var(--vd-radius-md, 12px);
|
||||
}
|
||||
|
||||
#image-factory .image-factory-profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
#image-factory .image-factory-profile-grid > * {
|
||||
min-width: 0;
|
||||
border-radius: var(--vd-radius-md, 12px);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-stage-review > label.field {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-advanced {
|
||||
margin: 16px 0;
|
||||
border: 1px solid var(--ux-factory-border);
|
||||
border-radius: var(--vd-radius-lg, 14px);
|
||||
background: var(--vd-surface-subtle, var(--muted-bg, #f7f5f2));
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-advanced > summary {
|
||||
padding: 14px 16px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-advanced .image-factory-options-grid {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
#image-factory .image-factory-submit {
|
||||
position: sticky;
|
||||
bottom: 12px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--vd-accent, var(--accent, #db4b2f)) 25%, var(--ux-factory-border));
|
||||
border-radius: var(--vd-radius-lg, 14px);
|
||||
background: color-mix(in srgb, var(--vd-surface, var(--card-bg, #fff)) 94%, transparent);
|
||||
box-shadow: 0 12px 28px rgba(25, 22, 18, .12);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
#image-factory[data-ux-factory-ready="false"] .image-factory-submit {
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-monitor {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ux-factory-border);
|
||||
border-radius: var(--vd-radius-xl, 18px);
|
||||
background: var(--vd-surface, var(--card-bg, #fff));
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-monitor > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 15px 18px;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-monitor > summary > span:first-child strong,
|
||||
#image-factory .ux-image-factory-monitor > summary > span:first-child small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-monitor > summary > span:first-child small {
|
||||
margin-top: 3px;
|
||||
color: var(--ux-factory-muted);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-monitor-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--vd-surface-subtle, var(--muted-bg, #f2f0ec));
|
||||
color: var(--ux-factory-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-monitor-badge[data-tone="working"] {
|
||||
background: color-mix(in srgb, var(--vd-warning, #b77712) 14%, transparent);
|
||||
color: var(--vd-warning, #9a650e);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-monitor-badge[data-tone="danger"] {
|
||||
background: color-mix(in srgb, var(--vd-danger, #c63f3f) 14%, transparent);
|
||||
color: var(--vd-danger, #b12f2f);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-monitor-badge[data-tone="success"] {
|
||||
background: color-mix(in srgb, var(--vd-success, #2d8a61) 14%, transparent);
|
||||
color: var(--vd-success, #2d8a61);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-monitor .image-factory-status {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 0 18px 18px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#image-factory .image-factory-jobs {
|
||||
scroll-margin-top: 92px;
|
||||
padding: clamp(17px, 2.2vw, 24px);
|
||||
border-radius: var(--vd-radius-xl, 18px);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-job-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin: 14px 0 16px;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-job-filter button.active {
|
||||
border-color: var(--vd-accent, var(--accent, #db4b2f));
|
||||
background: var(--ux-factory-soft);
|
||||
color: var(--vd-accent, var(--accent, #db4b2f));
|
||||
}
|
||||
|
||||
#image-factory #image-factory-jobs {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#image-factory #image-factory-jobs > * {
|
||||
border-radius: var(--vd-radius-md, 12px);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step:focus-visible,
|
||||
#image-factory .ux-image-factory-next:focus-visible,
|
||||
#image-factory .ux-image-factory-job-filter button:focus-visible,
|
||||
#image-factory .ux-image-factory-source-controls > summary:focus-visible,
|
||||
#image-factory .ux-image-factory-monitor > summary:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--vd-accent, var(--accent, #db4b2f)) 34%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
#image-factory .ux-image-factory-steps {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
#image-factory .image-factory-source-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
#image-factory .ux-image-factory-workflow-head,
|
||||
#image-factory .image-factory-submit,
|
||||
#image-factory .ux-image-factory-monitor > summary,
|
||||
#image-factory .image-factory-jobs > .section-title-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-next {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-steps {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas: "index copy state";
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step-state {
|
||||
grid-area: state;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
#image-factory .image-factory-profile-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
#image-factory .image-factory-submit {
|
||||
position: static;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#image-factory .image-factory-submit button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 470px) {
|
||||
#image-factory .ux-image-factory-workflow,
|
||||
#image-factory .ux-image-factory-stage,
|
||||
#image-factory .image-factory-jobs {
|
||||
padding: 14px;
|
||||
border-radius: var(--vd-radius-lg, 14px);
|
||||
}
|
||||
|
||||
#image-factory .image-factory-source-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
max-height: 52vh;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-source-controls-body,
|
||||
#image-factory .ux-image-factory-job-filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-source-controls-body > *,
|
||||
#image-factory .ux-image-factory-job-filter button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#image-factory .ux-image-factory-step {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#image-factory .ux-image-factory-step:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const ROOT_ID = 'image-factory';
|
||||
const JOB_FILTER_KEY = 'vendoo_image_factory_job_filter';
|
||||
const JOB_FILTERS = ['all', 'active', 'completed', 'issues'];
|
||||
|
||||
const createNode = (tag, className = '', text = '') => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
const scrollToNode = node => {
|
||||
if (!node) return;
|
||||
node.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
window.requestAnimationFrame(() => {
|
||||
const focusTarget = node.matches('input,button,select,textarea') ? node : node.querySelector('input,button,select,textarea');
|
||||
focusTarget?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
|
||||
function parseSelectionSummary(root) {
|
||||
const summary = root.querySelector('#image-factory-selection-count')?.textContent || '';
|
||||
const match = summary.match(/(\d+)\s+Bilder?.*?(\d+)\s+Profile?/i);
|
||||
if (match) return { sources: Number(match[1]), profiles: Number(match[2]) };
|
||||
|
||||
const sourceGrid = root.querySelector('#image-factory-source-grid');
|
||||
const profileGrid = root.querySelector('#image-factory-profile-grid');
|
||||
const countSelected = container => {
|
||||
if (!container) return 0;
|
||||
const checked = container.querySelectorAll('input[type="checkbox"]:checked, input[type="radio"]:checked').length;
|
||||
if (checked) return checked;
|
||||
return container.querySelectorAll('.is-selected, .selected, [aria-pressed="true"]').length;
|
||||
};
|
||||
return { sources: countSelected(sourceGrid), profiles: countSelected(profileGrid) };
|
||||
}
|
||||
|
||||
function productionCounts(root) {
|
||||
const read = status => Number(root.querySelector(`[data-batch-summary="${status}"]`)?.textContent || 0);
|
||||
return {
|
||||
queued: read('queued'),
|
||||
running: read('running'),
|
||||
paused: read('paused'),
|
||||
completed: read('completed'),
|
||||
partial: read('partial'),
|
||||
failed: read('failed'),
|
||||
};
|
||||
}
|
||||
|
||||
function createStep(number, id, title, copy) {
|
||||
const button = createNode('button', 'ux-image-factory-step');
|
||||
button.type = 'button';
|
||||
button.dataset.uxFactoryStep = id;
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
|
||||
const index = createNode('span', 'ux-image-factory-step-index', String(number));
|
||||
const body = createNode('span', 'ux-image-factory-step-copy');
|
||||
body.append(createNode('strong', '', title), createNode('small', '', copy));
|
||||
const state = createNode('span', 'ux-image-factory-step-state', 'Offen');
|
||||
state.dataset.uxFactoryStepState = id;
|
||||
button.append(index, body, state);
|
||||
return button;
|
||||
}
|
||||
|
||||
function createWorkflow(root) {
|
||||
if (root.querySelector('.ux-image-factory-workflow')) return;
|
||||
const layout = root.querySelector('.image-factory-layout');
|
||||
if (!layout) return;
|
||||
|
||||
const workflow = createNode('section', 'ux-image-factory-workflow');
|
||||
workflow.setAttribute('aria-label', 'Ablauf der Bildproduktion');
|
||||
|
||||
const heading = createNode('div', 'ux-image-factory-workflow-head');
|
||||
const headingCopy = createNode('div');
|
||||
headingCopy.append(
|
||||
createNode('span', 'ux-image-factory-kicker', 'Geführte Bildproduktion'),
|
||||
createNode('h3', '', 'Vom Quellbild zur fertigen Ausgabe'),
|
||||
createNode('p', '', 'Vendoo zeigt nur den jeweils relevanten Arbeitsschritt. Bestehende Profile, Jobs und Dateien bleiben unverändert.')
|
||||
);
|
||||
const next = createNode('button', 'vd-button primary ux-image-factory-next', 'Quellbilder auswählen');
|
||||
next.type = 'button';
|
||||
next.dataset.uxFactoryNext = 'sources';
|
||||
heading.append(headingCopy, next);
|
||||
|
||||
const steps = createNode('div', 'ux-image-factory-steps');
|
||||
steps.append(
|
||||
createStep(1, 'sources', 'Quellbilder', 'Bilder für den Auftrag wählen'),
|
||||
createStep(2, 'profiles', 'Ausgabeprofile', 'Formate und Qualitätsziele festlegen'),
|
||||
createStep(3, 'review', 'Prüfen & starten', 'Name, Optionen und Umfang kontrollieren'),
|
||||
createStep(4, 'jobs', 'Aufträge & Ergebnisse', 'Fortschritt und Fehler überwachen')
|
||||
);
|
||||
|
||||
workflow.append(heading, steps);
|
||||
layout.before(workflow);
|
||||
}
|
||||
|
||||
function createStage(id, number, title, copy) {
|
||||
const stage = createNode('section', `ux-image-factory-stage ux-image-factory-stage-${id}`);
|
||||
stage.dataset.uxFactoryStage = id;
|
||||
stage.id = `ux-image-factory-stage-${id}`;
|
||||
|
||||
const head = createNode('header', 'ux-image-factory-stage-head');
|
||||
const index = createNode('span', 'ux-image-factory-stage-index', String(number).padStart(2, '0'));
|
||||
const text = createNode('div');
|
||||
text.append(createNode('h3', '', title), createNode('p', '', copy));
|
||||
head.append(index, text);
|
||||
stage.append(head);
|
||||
return stage;
|
||||
}
|
||||
|
||||
function restructureConfiguration(root) {
|
||||
const config = root.querySelector('.image-factory-config');
|
||||
if (!config || config.dataset.uxFactoryStructured === 'true') return;
|
||||
|
||||
const sourceHead = config.querySelector('.image-factory-source-head');
|
||||
const sourceGrid = config.querySelector('#image-factory-source-grid');
|
||||
const profileHead = config.querySelector('.image-factory-profile-head');
|
||||
const profileGrid = config.querySelector('#image-factory-profile-grid');
|
||||
const nameField = config.querySelector(':scope > label.field');
|
||||
const options = config.querySelector('.image-factory-options');
|
||||
const submit = config.querySelector('.image-factory-submit');
|
||||
if (!sourceHead || !sourceGrid || !profileHead || !profileGrid || !submit) return;
|
||||
|
||||
const sourceStage = createStage('sources', 1, 'Quellbilder auswählen', 'Suche oder filtere die Medienbibliothek und markiere die Bilder, die verarbeitet werden sollen.');
|
||||
const sourceTools = sourceHead.querySelector('.image-factory-source-tools');
|
||||
const sourceSearch = sourceTools?.querySelector('#image-factory-source-search');
|
||||
const sourceControls = createNode('details', 'ux-image-factory-source-controls');
|
||||
const sourceControlsSummary = createNode('summary', '', 'Auswahlwerkzeuge');
|
||||
const sourceControlsBody = createNode('div', 'ux-image-factory-source-controls-body');
|
||||
if (sourceTools) {
|
||||
if (sourceSearch) sourceStage.append(sourceSearch);
|
||||
[...sourceTools.children].forEach(child => sourceControlsBody.append(child));
|
||||
sourceControls.append(sourceControlsSummary, sourceControlsBody);
|
||||
}
|
||||
sourceStage.append(sourceHead, sourceControls, sourceGrid);
|
||||
|
||||
const profileStage = createStage('profiles', 2, 'Ausgabeprofile festlegen', 'Wähle ein oder mehrere Zielformate. Jedes Profil erzeugt pro Quellbild eine eigenständige Ausgabe.');
|
||||
profileStage.append(profileHead, profileGrid);
|
||||
|
||||
const reviewStage = createStage('review', 3, 'Auftrag prüfen und starten', 'Vergib einen Namen, kontrolliere den Ausgabeumfang und aktiviere nur bei Bedarf zusätzliche Verarbeitung.');
|
||||
if (nameField) reviewStage.append(nameField);
|
||||
if (options) {
|
||||
options.open = false;
|
||||
options.classList.add('ux-image-factory-advanced');
|
||||
reviewStage.append(options);
|
||||
}
|
||||
reviewStage.append(submit);
|
||||
|
||||
const originalTitle = config.querySelector(':scope > .section-title-row');
|
||||
originalTitle?.remove();
|
||||
config.append(sourceStage, profileStage, reviewStage);
|
||||
config.dataset.uxFactoryStructured = 'true';
|
||||
}
|
||||
|
||||
function createMonitor(root) {
|
||||
const status = root.querySelector('.image-factory-status');
|
||||
const layout = root.querySelector('.image-factory-layout');
|
||||
if (!status || !layout || root.querySelector('.ux-image-factory-monitor')) return;
|
||||
|
||||
const monitor = createNode('details', 'ux-image-factory-monitor');
|
||||
const summary = createNode('summary');
|
||||
const summaryCopy = createNode('span');
|
||||
summaryCopy.append(createNode('strong', '', 'Produktionsmonitor'), createNode('small', '', 'Wartende, laufende und fehlerhafte Aufträge'));
|
||||
const badge = createNode('span', 'ux-image-factory-monitor-badge', 'Keine aktiven Aufträge');
|
||||
badge.dataset.uxFactoryMonitorBadge = 'true';
|
||||
summary.append(summaryCopy, badge);
|
||||
monitor.append(summary, status);
|
||||
layout.append(monitor);
|
||||
}
|
||||
|
||||
function createJobControls(root) {
|
||||
const jobs = root.querySelector('.image-factory-jobs');
|
||||
const list = root.querySelector('#image-factory-jobs');
|
||||
if (!jobs || !list || jobs.querySelector('.ux-image-factory-job-filter')) return;
|
||||
|
||||
jobs.id = 'ux-image-factory-stage-jobs';
|
||||
const filter = createNode('div', 'ux-image-factory-job-filter');
|
||||
filter.setAttribute('role', 'group');
|
||||
filter.setAttribute('aria-label', 'Produktionsaufträge filtern');
|
||||
const definitions = [
|
||||
['all', 'Alle'],
|
||||
['active', 'Aktiv'],
|
||||
['completed', 'Fertig'],
|
||||
['issues', 'Probleme'],
|
||||
];
|
||||
definitions.forEach(([value, label]) => {
|
||||
const button = createNode('button', 'vd-button compact', label);
|
||||
button.type = 'button';
|
||||
button.dataset.uxFactoryJobFilter = value;
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
filter.append(button);
|
||||
});
|
||||
|
||||
const titleRow = jobs.querySelector(':scope > .section-title-row');
|
||||
titleRow?.after(filter);
|
||||
let stored = 'all';
|
||||
try { stored = localStorage.getItem(JOB_FILTER_KEY) || 'all'; } catch {}
|
||||
root.dataset.uxFactoryJobFilter = JOB_FILTERS.includes(stored) ? stored : 'all';
|
||||
}
|
||||
|
||||
function classifyJob(node) {
|
||||
if (node.classList.contains('empty-state')) return 'empty';
|
||||
const text = (node.textContent || '').toLowerCase();
|
||||
if (/fehler|fehlgeschlagen|teilweise|failed|partial|abgebrochen|cancelled/.test(text)) return 'issues';
|
||||
if (/wartend|läuft|laufend|pausiert|queued|running|paused|in bearbeitung/.test(text)) return 'active';
|
||||
if (/fertig|abgeschlossen|completed|erfolgreich|success/.test(text)) return 'completed';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function applyJobFilter(root) {
|
||||
const selected = JOB_FILTERS.includes(root.dataset.uxFactoryJobFilter) ? root.dataset.uxFactoryJobFilter : 'all';
|
||||
root.querySelectorAll('[data-ux-factory-job-filter]').forEach(button => {
|
||||
const active = button.dataset.uxFactoryJobFilter === selected;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
|
||||
const list = root.querySelector('#image-factory-jobs');
|
||||
if (!list) return;
|
||||
[...list.children].forEach(job => {
|
||||
const category = classifyJob(job);
|
||||
job.hidden = selected !== 'all' && category !== 'empty' && category !== selected;
|
||||
});
|
||||
}
|
||||
|
||||
function updateWorkflow(root) {
|
||||
const selection = parseSelectionSummary(root);
|
||||
const counts = productionCounts(root);
|
||||
const hasSources = selection.sources > 0;
|
||||
const hasProfiles = selection.profiles > 0;
|
||||
const ready = hasSources && hasProfiles;
|
||||
const activeJobs = counts.queued + counts.running + counts.paused;
|
||||
const issues = counts.failed + counts.partial;
|
||||
const hasJobs = activeJobs + counts.completed + issues > 0;
|
||||
|
||||
root.dataset.uxFactoryHasSources = String(hasSources);
|
||||
root.dataset.uxFactoryHasProfiles = String(hasProfiles);
|
||||
root.dataset.uxFactoryReady = String(ready);
|
||||
root.dataset.uxFactoryHasJobs = String(hasJobs);
|
||||
|
||||
const current = !hasSources ? 'sources' : !hasProfiles ? 'profiles' : activeJobs || issues ? 'jobs' : 'review';
|
||||
root.dataset.uxFactoryCurrentStep = current;
|
||||
|
||||
const completed = {
|
||||
sources: hasSources,
|
||||
profiles: hasProfiles,
|
||||
review: ready && hasJobs,
|
||||
jobs: hasJobs && activeJobs === 0 && issues === 0,
|
||||
};
|
||||
|
||||
root.querySelectorAll('[data-ux-factory-step]').forEach(step => {
|
||||
const id = step.dataset.uxFactoryStep;
|
||||
const isCurrent = id === current;
|
||||
step.classList.toggle('is-current', isCurrent);
|
||||
step.classList.toggle('is-complete', Boolean(completed[id]));
|
||||
step.setAttribute('aria-pressed', String(isCurrent));
|
||||
const state = step.querySelector('[data-ux-factory-step-state]');
|
||||
if (state) state.textContent = completed[id] ? 'Erledigt' : isCurrent ? 'Jetzt' : 'Offen';
|
||||
});
|
||||
|
||||
const next = root.querySelector('[data-ux-factory-next]');
|
||||
if (next) {
|
||||
if (!hasSources) {
|
||||
next.dataset.uxFactoryNext = 'sources';
|
||||
next.textContent = 'Quellbilder auswählen';
|
||||
} else if (!hasProfiles) {
|
||||
next.dataset.uxFactoryNext = 'profiles';
|
||||
next.textContent = 'Ausgabeprofile wählen';
|
||||
} else if (activeJobs || issues) {
|
||||
next.dataset.uxFactoryNext = 'jobs';
|
||||
next.textContent = issues ? 'Probleme prüfen' : 'Produktion überwachen';
|
||||
} else {
|
||||
next.dataset.uxFactoryNext = 'submit';
|
||||
next.textContent = 'Auftrag prüfen & starten';
|
||||
}
|
||||
}
|
||||
|
||||
const badge = root.querySelector('[data-ux-factory-monitor-badge]');
|
||||
if (badge) {
|
||||
badge.textContent = issues
|
||||
? `${issues} Problem${issues === 1 ? '' : 'e'}`
|
||||
: activeJobs
|
||||
? `${activeJobs} aktiv`
|
||||
: counts.completed
|
||||
? `${counts.completed} fertig`
|
||||
: 'Keine aktiven Aufträge';
|
||||
badge.dataset.tone = issues ? 'danger' : activeJobs ? 'working' : counts.completed ? 'success' : 'neutral';
|
||||
}
|
||||
|
||||
const monitor = root.querySelector('.ux-image-factory-monitor');
|
||||
if (monitor && !monitor.dataset.uxFactoryInitialized) {
|
||||
monitor.open = activeJobs > 0 || issues > 0;
|
||||
monitor.dataset.uxFactoryInitialized = 'true';
|
||||
}
|
||||
applyJobFilter(root);
|
||||
}
|
||||
|
||||
function stageTarget(root, id) {
|
||||
if (id === 'jobs') return root.querySelector('#ux-image-factory-stage-jobs');
|
||||
return root.querySelector(`#ux-image-factory-stage-${id}`);
|
||||
}
|
||||
|
||||
function bindActions(root) {
|
||||
root.addEventListener('click', event => {
|
||||
const step = event.target.closest('[data-ux-factory-step]');
|
||||
if (step) {
|
||||
scrollToNode(stageTarget(root, step.dataset.uxFactoryStep));
|
||||
return;
|
||||
}
|
||||
|
||||
const next = event.target.closest('[data-ux-factory-next]');
|
||||
if (next) {
|
||||
const target = next.dataset.uxFactoryNext;
|
||||
if (target === 'submit') {
|
||||
scrollToNode(stageTarget(root, 'review'));
|
||||
root.querySelector('#image-factory-submit')?.focus({ preventScroll: true });
|
||||
} else {
|
||||
scrollToNode(stageTarget(root, target));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = event.target.closest('[data-ux-factory-job-filter]');
|
||||
if (filter) {
|
||||
root.dataset.uxFactoryJobFilter = filter.dataset.uxFactoryJobFilter;
|
||||
try { localStorage.setItem(JOB_FILTER_KEY, root.dataset.uxFactoryJobFilter); } catch {}
|
||||
applyJobFilter(root);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function observeFactory(root) {
|
||||
const observer = new MutationObserver(() => window.requestAnimationFrame(() => updateWorkflow(root)));
|
||||
const targets = [
|
||||
root.querySelector('#image-factory-selection-count'),
|
||||
root.querySelector('#image-factory-source-grid'),
|
||||
root.querySelector('#image-factory-profile-grid'),
|
||||
root.querySelector('#image-factory-summary'),
|
||||
root.querySelector('#image-factory-jobs'),
|
||||
].filter(Boolean);
|
||||
targets.forEach(target => observer.observe(target, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'checked', 'aria-pressed'] }));
|
||||
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
|
||||
}
|
||||
|
||||
function initializeImageFactoryUx() {
|
||||
const root = document.getElementById(ROOT_ID);
|
||||
if (!root || root.dataset.uxImageFactoryReady === 'true') return;
|
||||
createWorkflow(root);
|
||||
restructureConfiguration(root);
|
||||
createMonitor(root);
|
||||
createJobControls(root);
|
||||
bindActions(root);
|
||||
observeFactory(root);
|
||||
root.dataset.uxImageFactoryReady = 'true';
|
||||
root.classList.add('ux-image-factory-ready');
|
||||
updateWorkflow(root);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeImageFactoryUx, { once: true });
|
||||
} else {
|
||||
initializeImageFactoryUx();
|
||||
}
|
||||
})();
|
||||
+85
-54
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="vendoo-build" content="1.41.2">
|
||||
<meta name="vendoo-build" content="1.43.0">
|
||||
<title>Vendoo</title>
|
||||
<link rel="icon" href="/brand/favicons/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/brand/favicons/favicon-16x16.png">
|
||||
@@ -13,9 +13,9 @@
|
||||
<link rel="manifest" href="/brand/favicons/site.webmanifest">
|
||||
<meta name="msapplication-config" content="/brand/favicons/browserconfig.xml">
|
||||
<meta name="theme-color" content="#FAF6F2">
|
||||
<link rel="stylesheet" href="style.css?v=1.41.2">
|
||||
<link rel="stylesheet" href="modules/theme-manager/theme-manager.css?v=1.41.2">
|
||||
<link rel="stylesheet" href="modules/module-manager/module-manager.css?v=1.41.2">
|
||||
<link rel="stylesheet" href="style.css?v=1.43.0">
|
||||
<link rel="stylesheet" href="modules/theme-manager/theme-manager.css?v=1.43.0">
|
||||
<link rel="stylesheet" href="modules/module-manager/module-manager.css?v=1.43.0">
|
||||
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
@@ -36,55 +36,55 @@
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="generator">
|
||||
<button class="nav-item" data-tab="generator" data-module="vendoo.ai">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/><rect x="3" y="3" width="18" height="18" rx="3"/></svg>
|
||||
<span>Neuer Artikel</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="today">
|
||||
<button class="nav-item" data-tab="today" data-module="vendoo.listings">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11.5 12 4l9 7.5"/><path d="M5.5 10.5V20h13v-9.5"/><path d="M9.5 20v-6h5v6"/></svg>
|
||||
<span>Heute</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="history">
|
||||
<button class="nav-item" data-tab="history" data-module="vendoo.listings">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 7h8M8 11h8M8 15h5"/></svg>
|
||||
<span>Artikel</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="quality-center">
|
||||
<button class="nav-item" data-tab="quality-center" data-module="vendoo.quality">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 20 7v5c0 5-3.4 8.3-8 10-4.6-1.7-8-5-8-10V7Z"/><path d="m8.5 12 2.2 2.2 4.8-5"/></svg>
|
||||
<span>Qualitätscenter</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="publish">
|
||||
<button class="nav-item" data-tab="publish" data-module="vendoo.publishing">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
|
||||
<span>Publish</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="inventory">
|
||||
<button class="nav-item" data-tab="inventory" data-module="vendoo.inventory">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m4 7 8-4 8 4-8 4Z"/><path d="m4 7 8 4 8-4v10l-8 4-8-4Z"/><path d="M12 11v10"/></svg>
|
||||
<span>Lager</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="templates">
|
||||
<button class="nav-item" data-tab="templates" data-module="vendoo.listings">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/></svg>
|
||||
<span>Vorlagen</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="flux-studio">
|
||||
<button class="nav-item" data-tab="flux-studio" data-module="vendoo.flux-studio">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 9.8 8.2 4 10l5 3.2L8 19l4-3 4 3-1-5.8 5-3.2-5.8-1.8Z"/><path d="M18 3v4M16 5h4"/></svg>
|
||||
<span>FLUX Studio</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="media-library">
|
||||
<button class="nav-item" data-tab="media-library" data-module="vendoo.media">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="m4 17 5-5 4 4 2-2 5 5"/></svg>
|
||||
<span>Bilder</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="image-factory">
|
||||
<button class="nav-item" data-tab="image-factory" data-module="vendoo.ai">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4z"/><path d="M14 17h6M17 14v6"/></svg>
|
||||
<span>Bildproduktion</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="extensions">
|
||||
<button class="nav-item" data-tab="extensions" data-module="vendoo.module-manager">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 3v4M15.5 3v4M6 7h12v4a6 6 0 0 1-12 0Z"/><path d="M12 17v4M9 21h6"/></svg>
|
||||
<span>Extensions</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="settings">
|
||||
<button class="nav-item" data-tab="settings" data-module="vendoo.settings">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.05.05-2.83 2.83-.05-.05a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.04 1.57V21h-4v-.07a1.7 1.7 0 0 0-1.04-1.57 1.7 1.7 0 0 0-1.87.34l-.05.05-2.83-2.83.05-.05A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.57-1.04H3v-4h.07A1.7 1.7 0 0 0 4.64 8.9a1.7 1.7 0 0 0-.34-1.87l-.05-.05 2.83-2.83.05.05A1.7 1.7 0 0 0 9 4.54 1.7 1.7 0 0 0 10.04 3H10V3h4v.07A1.7 1.7 0 0 0 15.04 4.6a1.7 1.7 0 0 0 1.87-.34l.05-.05 2.83 2.83-.05.05A1.7 1.7 0 0 0 19.4 9c.24.63.85 1.04 1.53 1.04H21v4h-.07c-.68 0-1.29.41-1.53 1.04Z"/></svg>
|
||||
<span>Einstellungen</span>
|
||||
</button>
|
||||
<button class="nav-item" data-tab="admin" data-admin-tab="users" id="nav-admin-main">
|
||||
<button class="nav-item" data-tab="admin" data-module="vendoo.users" data-admin-tab="users" id="nav-admin-main">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="M9 12h6M12 9v6"/></svg>
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
@@ -92,11 +92,11 @@
|
||||
|
||||
<div class="nav-group nav-group-tools" id="nav-system-group">
|
||||
<div class="nav-divider"></div>
|
||||
<button class="nav-item nav-item-quiet" data-tab="fees">
|
||||
<button class="nav-item nav-item-quiet" data-tab="fees" data-module="vendoo.publishing">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 7h8M8 11h3M8 15h3M15 11v5M13 13.5h4"/></svg>
|
||||
<span>Gebühren</span>
|
||||
</button>
|
||||
<button class="nav-item nav-item-quiet" data-tab="trash">
|
||||
<button class="nav-item nav-item-quiet" data-tab="trash" data-module="vendoo.listings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14"/></svg>
|
||||
<span>Papierkorb</span>
|
||||
<span id="trash-badge" class="nav-badge hidden">0</span>
|
||||
@@ -145,7 +145,7 @@
|
||||
</button>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<button class="topbar-btn primary" id="topbar-new-btn">
|
||||
<button class="topbar-btn primary" id="topbar-new-btn" data-module="vendoo.ai">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span>Neuer Artikel</span>
|
||||
</button>
|
||||
@@ -314,18 +314,39 @@
|
||||
<p>Fotos erfassen, AI gezielt steuern und das Ergebnis ohne Kontextwechsel fertigstellen.</p>
|
||||
</div>
|
||||
<div class="generator-heading-actions">
|
||||
<div class="generator-stepper" aria-label="Arbeitsfortschritt">
|
||||
<span class="generator-step is-active" id="generator-step-photos"><b>1</b> Fotos</span>
|
||||
<span class="generator-step" id="generator-step-ai"><b>2</b> AI</span>
|
||||
<span class="generator-step" id="generator-step-result"><b>3</b> Prüfen</span>
|
||||
</div>
|
||||
<button id="reset-generator-btn" class="vd-button secondary" type="button">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>
|
||||
Zurücksetzen
|
||||
Formular leeren
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="generator-commandbar" id="generator-commandbar">
|
||||
<div class="generator-command-progress">
|
||||
<div class="generator-stepper" aria-label="Arbeitsfortschritt">
|
||||
<span class="generator-step is-active" id="generator-step-photos"><b>1</b> Fotos</span>
|
||||
<span class="generator-step" id="generator-step-ai"><b>2</b> Generieren</span>
|
||||
<span class="generator-step" id="generator-step-result"><b>3</b> Prüfen</span>
|
||||
</div>
|
||||
<div class="generator-readiness">
|
||||
<span id="generator-readiness-dot"></span>
|
||||
<div>
|
||||
<strong id="generator-readiness-title">Fotos fehlen</strong>
|
||||
<small id="generator-readiness-copy">Lade mindestens ein Produktfoto hoch.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="generator-command-actions">
|
||||
<button id="generate-btn" class="vd-button primary generator-primary-action" disabled>
|
||||
<span aria-hidden="true">✦</span>
|
||||
Artikel generieren
|
||||
</button>
|
||||
<button id="save-btn" class="vd-button generator-save-action" disabled>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="generator-studio-layout">
|
||||
<section class="generator-panel media-workbench" aria-labelledby="generator-media-title">
|
||||
<div class="generator-panel-header">
|
||||
@@ -377,13 +398,13 @@
|
||||
</div>
|
||||
|
||||
|
||||
<section id="generator-ai-model-card" class="ai-model-card generator-ai-model-card">
|
||||
<section id="generator-ai-model-card" class="ai-model-card generator-ai-model-card hidden" data-module-panel="vendoo.product-image-tools">
|
||||
<div class="ai-model-card-head">
|
||||
<div>
|
||||
<span class="generator-panel-index">AI</span>
|
||||
<div>
|
||||
<h4>Produktbild-Werkzeuge</h4>
|
||||
<p>Erzeuge Ghost-Mannequin- oder Flatlay-Varianten aus dem vorhandenen Produktfoto. Freie Prompt-Bilder findest du im separaten FLUX Studio.</p>
|
||||
<h4>Produktbild Studio</h4>
|
||||
<p>Optionales Modul für kontrollierte Ghost-Mannequin- und Flatlay-Varianten. Aktivierung und Deaktivierung erfolgen im Modulmanager.</p>
|
||||
</div>
|
||||
</div>
|
||||
<span id="generator-ai-model-badge" class="generator-beta">Safe</span>
|
||||
@@ -533,19 +554,7 @@
|
||||
<p><strong>AI analysiert deine Fotos</strong><br><span id="generator-loading-copy">Produkt, Zustand und Marktplatzdaten werden vorbereitet.</span></p>
|
||||
</div>
|
||||
|
||||
<div class="generator-action-zone">
|
||||
<div class="generator-readiness">
|
||||
<span id="generator-readiness-dot"></span>
|
||||
<div>
|
||||
<strong id="generator-readiness-title">Fotos fehlen</strong>
|
||||
<small id="generator-readiness-copy">Lade mindestens ein Produktfoto hoch.</small>
|
||||
</div>
|
||||
</div>
|
||||
<button id="generate-btn" class="primary-btn generator-primary-action" disabled>
|
||||
<span>✦</span>
|
||||
Artikel generieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<aside class="generator-panel generator-preview-panel" aria-labelledby="generator-preview-title">
|
||||
@@ -649,7 +658,7 @@
|
||||
|
||||
<div class="result-actions generator-result-actions">
|
||||
<button id="copy-all-btn" class="secondary-btn">Alles kopieren</button>
|
||||
<button id="save-btn" class="primary-btn">Speichern & Hinzufügen</button>
|
||||
<span>Speichern ist dauerhaft oben erreichbar.</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -1122,6 +1131,10 @@
|
||||
<!-- Users Tab -->
|
||||
<div class="admin-panel active" id="admin-users-panel">
|
||||
<div id="users-list" class="users-list"></div>
|
||||
<section class="invitation-admin-section">
|
||||
<div class="security-panel-heading"><div><p class="eyebrow">Testzugänge</p><h3>Einladungen</h3><p>Offene, abgelaufene und widerrufene Einladungen. Standardrolle ist Leser; höhere Rechte müssen bewusst gewählt werden.</p></div></div>
|
||||
<div id="invitations-list" class="invitation-admin-list"><div class="empty-state">Einladungen werden geladen …</div></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Roles Tab -->
|
||||
@@ -1256,27 +1269,45 @@
|
||||
<div class="operation-card-actions"><button type="button" class="secondary-btn" id="operations-update-url-save">URL speichern</button><button type="button" class="secondary-btn" id="operations-update-check">Jetzt prüfen</button></div>
|
||||
<div id="operations-update-check-result" class="operation-result">Noch nicht geprüft.</div>
|
||||
</article>
|
||||
<article class="operations-card operations-production-card">
|
||||
<header><span data-vd-icon="activity"></span><div><h4>Produktionsstatus</h4><p>Live-Überblick über Version, Readiness, Datenbank, Speicher und FLUX-Grenze.</p></div></header>
|
||||
<div id="deployment-production-overview" class="deployment-production-overview"><div class="empty-state">Produktionsstatus wird geladen …</div></div>
|
||||
<div class="operation-card-actions"><button type="button" class="secondary-btn" id="deployment-production-check">Produktionscheck ausführen</button></div>
|
||||
<div id="deployment-production-check-result" class="operation-result">Noch kein Produktionscheck ausgeführt.</div>
|
||||
</article>
|
||||
<article class="operations-card operations-deployment-card">
|
||||
<header><span data-vd-icon="cloud-cog"></span><div><h4>Coolify & GitHub Deployment</h4><p>Neue GitHub-Versionen kontrolliert über Coolify ausrollen. Vendoo erhält keinen Docker-Socket.</p></div></header>
|
||||
<header><span data-vd-icon="cloud-cog"></span><div><h4>Coolify & GitHub Deployment</h4><p>Kontrolliertes Deployment ohne Docker-Socket und ohne selbständernden Produktivcontainer.</p></div></header>
|
||||
<label class="operation-field"><span>Provider</span><select id="deployment-provider"><option value="none">Nicht konfiguriert</option><option value="coolify">Coolify</option></select></label>
|
||||
<label class="operation-field"><span>Coolify URL</span><input id="deployment-coolify-url" type="url" placeholder="https://coolify.example.de"></label>
|
||||
<label class="operation-field"><span>Resource UUID</span><input id="deployment-resource-uuid" type="text" placeholder="Coolify Application UUID"></label>
|
||||
<label class="operation-field"><span>Trigger</span><select id="deployment-trigger-mode"><option value="webhook">Deploy-Webhook (empfohlen)</option><option value="api">Coolify API</option></select></label>
|
||||
<label class="operation-field"><span>Trigger</span><select id="deployment-trigger-mode"><option value="webhook">Deploy-Webhook</option><option value="api">Coolify API mit Statusabfrage</option></select></label>
|
||||
<label class="operation-field"><span>Deployment-Status-Endpunkt</span><input id="deployment-status-endpoint" type="text" value="/api/v1/deployments/{deployment_id}"></label>
|
||||
<div class="operation-inline-fields">
|
||||
<label class="operation-field"><span>Zeitlimit (Sek.)</span><input id="deployment-timeout-seconds" type="number" min="60" max="7200" value="900"></label>
|
||||
<label class="operation-field"><span>Prüfintervall (Sek.)</span><input id="deployment-poll-seconds" type="number" min="5" max="300" value="15"></label>
|
||||
</div>
|
||||
<label class="operation-field"><span>Deploy-Webhook</span><input id="deployment-webhook-url" type="password" autocomplete="new-password" placeholder="Nur neu eingeben, wenn er geändert werden soll"></label>
|
||||
<label class="operation-field"><span>API-Token</span><input id="deployment-api-token" type="password" autocomplete="new-password" placeholder="Nur für API-Modus"></label>
|
||||
<div class="operation-card-actions"><button type="button" class="secondary-btn" id="deployment-save">Konfiguration speichern</button><button type="button" class="secondary-btn" id="deployment-test">Verbindung prüfen</button></div>
|
||||
<div id="deployment-status" class="operation-result">Deployment-Konfiguration wird geladen …</div>
|
||||
</article>
|
||||
<article class="operations-card operations-deploy-action-card">
|
||||
<header><span data-vd-icon="rocket"></span><div><h4>GitHub-Stand ausrollen</h4><p>Coolify holt den konfigurierten Branch und ersetzt den laufenden Container erst nach erfolgreichem Build und Healthcheck.</p></div></header>
|
||||
<header><span data-vd-icon="rocket"></span><div><h4>GitHub-Stand kontrolliert ausrollen</h4><p>Vorab-Backup, Doppelstartschutz, Statusverfolgung, Readiness und Zielversion sind Bestandteil eines Auftrags.</p></div></header>
|
||||
<label class="operation-field"><span>GitHub Repository</span><input id="deployment-github-repository" type="text" value="Masterluke77/vendoo"></label>
|
||||
<label class="operation-field"><span>Produktionsbranch</span><input id="deployment-github-branch" type="text" value="main"></label>
|
||||
<label class="operation-field"><span>Update-Kanal</span><select id="deployment-update-channel"><option value="stable">Stable</option><option value="beta">Beta</option></select></label>
|
||||
<div class="operation-inline-fields">
|
||||
<label class="operation-field"><span>Update-Kanal</span><select id="deployment-update-channel"><option value="stable">Stable</option><option value="beta">Beta</option></select></label>
|
||||
<label class="operation-field"><span>Zielversion</span><input id="deployment-target-version" type="text" placeholder="z. B. 1.43.0"></label>
|
||||
</div>
|
||||
<label class="operation-check"><input id="deployment-backup-before" type="checkbox" checked><span>Vor dem Deployment ein verifiziertes Backup mit Konfiguration erstellen</span></label>
|
||||
<label class="operation-check"><input id="deployment-backup-uploads" type="checkbox"><span>Uploads zusätzlich in das Vorab-Backup aufnehmen</span></label>
|
||||
<div class="operation-card-actions"><button type="button" class="secondary-btn" id="deployment-update-check">Version prüfen</button><button type="button" class="primary-btn" id="deployment-trigger">Coolify-Deployment starten</button></div>
|
||||
<div id="deployment-action-result" class="operation-result">Noch kein Deployment aus dem Webinterface angefordert.</div>
|
||||
</article>
|
||||
<article class="operations-card operations-deployment-history-card">
|
||||
<header><span data-vd-icon="history"></span><div><h4>Deployment-Verlauf</h4><p>Persistente Aufträge und Ereignisse bleiben auch nach einem Neustart nachvollziehbar.</p></div></header>
|
||||
<div id="deployment-run-list" class="deployment-run-list"><div class="empty-state">Keine Deployment-Aufträge vorhanden.</div></div>
|
||||
</article>
|
||||
|
||||
<article class="operations-card operations-update-upload">
|
||||
<header><span data-vd-icon="package-open"></span><div><h4>Update-Paket vorbereiten</h4><p>Prüft Version, Pflichtdateien und verbotene Laufzeitdaten. Vorher wird automatisch gesichert.</p></div></header>
|
||||
@@ -1378,9 +1409,9 @@ PUBLIC_BASE_URL=https://vendoo.example.de</pre>
|
||||
<div class="form-group">
|
||||
<label>Rolle</label>
|
||||
<select id="invite-role" class="model-select">
|
||||
<option value="viewer">Leser</option>
|
||||
<option value="viewer" selected>Leser</option>
|
||||
<option value="publisher">Publisher</option>
|
||||
<option value="editor" selected>Editor</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
@@ -2361,11 +2392,11 @@ PUBLIC_BASE_URL=https://vendoo.example.de</pre>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
|
||||
<script src="design-system/theme-runtime.js?v=1.41.2"></script>
|
||||
<script src="vendoo-icons.js?v=1.41.2"></script>
|
||||
<script src="app.js?v=1.41.2"></script>
|
||||
<script type="module" src="modules/theme-manager/theme-manager.js?v=1.41.2"></script>
|
||||
<script type="module" src="modules/module-manager/module-manager.js?v=1.41.2"></script>
|
||||
<script src="design-system/theme-runtime.js?v=1.43.0"></script>
|
||||
<script src="vendoo-icons.js?v=1.43.0"></script>
|
||||
<script src="app.js?v=1.43.0"></script>
|
||||
<script type="module" src="modules/theme-manager/theme-manager.js?v=1.43.0"></script>
|
||||
<script type="module" src="modules/module-manager/module-manager.js?v=1.43.0"></script>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
#inventory.ux-inventory-ready {
|
||||
--ux-inventory-gap: clamp(14px, 1.8vw, 24px);
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inventory-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: var(--ux-inventory-gap);
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inventory-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-overview {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-bottom: var(--ux-inventory-gap);
|
||||
padding: clamp(18px, 2.2vw, 28px);
|
||||
border: 1px solid var(--vd-border, rgba(33, 30, 27, .12));
|
||||
border-radius: var(--vd-radius-xl, 22px);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--vd-accent, #d95f35) 9%, var(--vd-surface, #fff)), var(--vd-surface, #fff));
|
||||
box-shadow: var(--vd-shadow-sm, 0 10px 30px rgba(26, 23, 20, .06));
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-overview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-kicker {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--vd-accent, #d95f35);
|
||||
font-size: .76rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-overview h3,
|
||||
#inventory.ux-inventory-ready .ux-inventory-overview p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-overview h3 {
|
||||
font-size: clamp(1.15rem, 2vw, 1.55rem);
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-overview p {
|
||||
max-width: 760px;
|
||||
margin-top: 6px;
|
||||
color: var(--vd-text-muted, #6f675f);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-next {
|
||||
flex: 0 0 auto;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--vd-border, #ddd) 76%, transparent);
|
||||
border-radius: var(--vd-radius-lg, 16px);
|
||||
background: color-mix(in srgb, var(--vd-surface, #fff) 88%, transparent);
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-metric-value {
|
||||
min-width: 48px;
|
||||
color: var(--vd-text, #26211d);
|
||||
font-size: clamp(1.25rem, 2.3vw, 1.75rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-metric-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-metric-copy b {
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-metric-copy small {
|
||||
overflow: hidden;
|
||||
color: var(--vd-text-muted, #6f675f);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready.ux-inventory-has-unassigned .ux-inventory-metric:first-child {
|
||||
border-color: color-mix(in srgb, var(--vd-danger, #b42318) 42%, var(--vd-border, #ddd));
|
||||
background: color-mix(in srgb, var(--vd-danger, #b42318) 7%, var(--vd-surface, #fff));
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready.ux-inventory-has-unassigned .ux-inventory-metric:first-child .ux-inventory-metric-value {
|
||||
color: var(--vd-danger, #b42318);
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-stats-secondary {
|
||||
margin-bottom: 12px;
|
||||
opacity: .82;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-toolbar {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-toolbar-primary,
|
||||
#inventory.ux-inventory-ready .ux-inventory-controls {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-search,
|
||||
#inventory.ux-inventory-ready .ux-inventory-sort,
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-search {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-search {
|
||||
flex: 1 1 420px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-search span,
|
||||
#inventory.ux-inventory-ready .ux-inventory-sort span,
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-search span {
|
||||
color: var(--vd-text-muted, #6f675f);
|
||||
font-size: .76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-search input,
|
||||
#inventory.ux-inventory-ready .ux-inventory-sort select,
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-search input {
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--vd-border, #ddd);
|
||||
border-radius: var(--vd-radius-md, 12px);
|
||||
background: var(--vd-surface, #fff);
|
||||
color: var(--vd-text, #26211d);
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-search input,
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-search input {
|
||||
width: 100%;
|
||||
padding: 0 13px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-sort select {
|
||||
padding: 0 36px 0 12px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-visible-count {
|
||||
flex: 0 0 auto;
|
||||
padding-bottom: 11px;
|
||||
color: var(--vd-text-muted, #6f675f);
|
||||
font-size: .85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-filter-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-filter {
|
||||
min-height: 38px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--vd-border, #ddd);
|
||||
border-radius: 999px;
|
||||
background: var(--vd-surface, #fff);
|
||||
color: var(--vd-text-muted, #6f675f);
|
||||
font: inherit;
|
||||
font-size: .84rem;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-filter[aria-pressed="true"] {
|
||||
border-color: color-mix(in srgb, var(--vd-accent, #d95f35) 52%, var(--vd-border, #ddd));
|
||||
background: color-mix(in srgb, var(--vd-accent, #d95f35) 11%, var(--vd-surface, #fff));
|
||||
color: var(--vd-accent-strong, #9c3d1d);
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inventory-locations {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inv-location-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 13px;
|
||||
min-height: 156px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--vd-border, rgba(33, 30, 27, .12));
|
||||
border-radius: var(--vd-radius-lg, 16px);
|
||||
background: var(--vd-surface, #fff);
|
||||
box-shadow: var(--vd-shadow-xs, 0 4px 14px rgba(26, 23, 20, .04));
|
||||
cursor: pointer;
|
||||
transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inv-location-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--vd-accent, #d95f35) 36%, var(--vd-border, #ddd));
|
||||
box-shadow: var(--vd-shadow-sm, 0 10px 30px rgba(26, 23, 20, .07));
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inv-location-card.ux-inventory-needs-attention {
|
||||
border-color: color-mix(in srgb, var(--vd-warning, #b7791f) 48%, var(--vd-border, #ddd));
|
||||
background: color-mix(in srgb, var(--vd-warning, #b7791f) 6%, var(--vd-surface, #fff));
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inv-location-card.ux-inventory-needs-attention::after {
|
||||
content: "Zuweisung erforderlich";
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
right: 13px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--vd-warning, #b7791f) 15%, var(--vd-surface, #fff));
|
||||
color: var(--vd-warning-strong, #7a4d08);
|
||||
font-size: .7rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inv-location-card.ux-inventory-filtered-out,
|
||||
#inventory.ux-inventory-ready .history-item.ux-inventory-detail-filtered {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inv-loc-header {
|
||||
padding-right: 88px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inv-location-card:not(.ux-inventory-needs-attention) .inv-loc-header {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inv-loc-name {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inv-loc-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inv-loc-stats span {
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--vd-surface-subtle, #f4f1ed);
|
||||
color: var(--vd-text-muted, #6f675f);
|
||||
font-size: .75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inv-loc-value {
|
||||
align-self: end;
|
||||
color: var(--vd-text, #26211d);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .inventory-detail {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-title-wrap h3 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-more-actions {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-more-actions > summary {
|
||||
min-height: 38px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--vd-border, #ddd);
|
||||
border-radius: var(--vd-radius-md, 12px);
|
||||
background: var(--vd-surface, #fff);
|
||||
color: var(--vd-text-muted, #6f675f);
|
||||
font-size: .82rem;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-more-actions > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-more-actions-body {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: calc(100% + 7px);
|
||||
right: 0;
|
||||
min-width: 190px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--vd-border, #ddd);
|
||||
border-radius: var(--vd-radius-md, 12px);
|
||||
background: var(--vd-surface, #fff);
|
||||
box-shadow: var(--vd-shadow-md, 0 14px 38px rgba(26, 23, 20, .14));
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-more-actions-body button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--vd-border, #ddd);
|
||||
border-radius: var(--vd-radius-lg, 16px);
|
||||
background: var(--vd-surface, #fff);
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-search {
|
||||
flex: 1 1 420px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-select-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
color: var(--vd-text-muted, #6f675f);
|
||||
font-size: .82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-selection-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--vd-accent, #d95f35) 35%, var(--vd-border, #ddd));
|
||||
border-radius: var(--vd-radius-md, 12px);
|
||||
background: color-mix(in srgb, var(--vd-accent, #d95f35) 7%, var(--vd-surface, #fff));
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-selection-bar.has-selection {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready #inv-detail-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready #inv-detail-list .history-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--vd-border, #ddd);
|
||||
border-radius: var(--vd-radius-md, 12px);
|
||||
background: var(--vd-surface, #fff);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready #inv-detail-list .history-item.ux-inventory-selected {
|
||||
border-color: color-mix(in srgb, var(--vd-accent, #d95f35) 48%, var(--vd-border, #ddd));
|
||||
background: color-mix(in srgb, var(--vd-accent, #d95f35) 6%, var(--vd-surface, #fff));
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready #inv-detail-list .listing-title {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready #inv-detail-list .listing-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--vd-text-muted, #6f675f);
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready #inv-detail-list .status-dot {
|
||||
display: inline-flex;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--vd-success, #16835d) 11%, var(--vd-surface, #fff));
|
||||
color: var(--vd-success-strong, #126448);
|
||||
font-size: .72rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready #inv-detail-list .status-dot.sold {
|
||||
background: var(--vd-surface-subtle, #f4f1ed);
|
||||
color: var(--vd-text-muted, #6f675f);
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready #inv-detail-list .status-dot.reserved {
|
||||
background: color-mix(in srgb, var(--vd-warning, #b7791f) 12%, var(--vd-surface, #fff));
|
||||
color: var(--vd-warning-strong, #7a4d08);
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready :is(button, input, select, summary, .inv-location-card):focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--vd-accent, #d95f35) 48%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
#inventory.ux-inventory-ready .ux-inventory-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-toolbar-primary,
|
||||
#inventory.ux-inventory-ready .ux-inventory-controls {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
#inventory.ux-inventory-ready .inventory-header,
|
||||
#inventory.ux-inventory-ready .ux-inventory-overview-head,
|
||||
#inventory.ux-inventory-ready .ux-inventory-toolbar-primary,
|
||||
#inventory.ux-inventory-ready .ux-inventory-controls,
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-head,
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-next,
|
||||
#inventory.ux-inventory-ready .inventory-actions-bar button,
|
||||
#inventory.ux-inventory-ready .ux-inventory-search,
|
||||
#inventory.ux-inventory-ready .ux-inventory-detail-search {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-visible-count {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-sort select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-more-actions-body {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 470px) {
|
||||
#inventory.ux-inventory-ready .ux-inventory-metrics,
|
||||
#inventory.ux-inventory-ready .inventory-locations {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-filter-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-selection-bar.has-selection {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#inventory.ux-inventory-ready .ux-inventory-selection-bar button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#inventory.ux-inventory-ready *,
|
||||
#inventory.ux-inventory-ready *::before,
|
||||
#inventory.ux-inventory-ready *::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: .01ms !important;
|
||||
animation-duration: .01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const ROOT_ID = 'inventory';
|
||||
const SORT_KEY = 'vendoo_inventory_sort';
|
||||
|
||||
const createNode = (tag, className = '', text = '') => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
const normalize = value => String(value || '').trim().toLocaleLowerCase('de-DE');
|
||||
const parseNumber = value => {
|
||||
const match = String(value ?? '').replace(/\./g, '').match(/-?[\d,]+/);
|
||||
return match ? Number(match[0].replace(',', '.')) || 0 : 0;
|
||||
};
|
||||
|
||||
function readStat(root, label) {
|
||||
const card = [...root.querySelectorAll('#inv-stats .inv-stat-card')]
|
||||
.find(item => normalize(item.querySelector('.inv-stat-label')?.textContent) === normalize(label));
|
||||
return card?.querySelector('.inv-stat-value')?.textContent?.trim() || '0';
|
||||
}
|
||||
|
||||
function locationMeta(card) {
|
||||
return {
|
||||
unassigned: card.classList.contains('inv-unassigned'),
|
||||
count: parseNumber(card.querySelector('.inv-loc-count')?.textContent),
|
||||
value: parseNumber(card.querySelector('.inv-loc-value')?.textContent),
|
||||
name: card.querySelector('.inv-loc-name')?.textContent?.trim() || '',
|
||||
};
|
||||
}
|
||||
|
||||
function createMetric(key, label, copy) {
|
||||
const item = createNode('article', 'ux-inventory-metric');
|
||||
const value = createNode('strong', 'ux-inventory-metric-value', '0');
|
||||
value.dataset.uxInventoryMetric = key;
|
||||
const body = createNode('span', 'ux-inventory-metric-copy');
|
||||
body.append(createNode('b', '', label), createNode('small', '', copy));
|
||||
item.append(value, body);
|
||||
return item;
|
||||
}
|
||||
|
||||
function createOverview(root) {
|
||||
const header = root.querySelector('.inventory-header');
|
||||
const stats = root.querySelector('#inv-stats');
|
||||
if (!header || !stats || root.querySelector('.ux-inventory-overview')) return;
|
||||
|
||||
const overview = createNode('section', 'ux-inventory-overview');
|
||||
overview.setAttribute('aria-label', 'Bestandsübersicht');
|
||||
const head = createNode('div', 'ux-inventory-overview-head');
|
||||
const copy = createNode('div');
|
||||
copy.append(
|
||||
createNode('span', 'ux-inventory-kicker', 'Bestand im Blick'),
|
||||
createNode('h3', '', 'Was braucht jetzt Aufmerksamkeit?'),
|
||||
createNode('p', '', 'Nicht zugewiesene Artikel zuerst einordnen, danach Lagerorte durchsuchen und Bestände gezielt bearbeiten.')
|
||||
);
|
||||
const next = createNode('button', 'vd-button primary ux-inventory-next', 'Bestand öffnen');
|
||||
next.type = 'button';
|
||||
next.dataset.uxInventoryNext = 'overview';
|
||||
head.append(copy, next);
|
||||
|
||||
const metrics = createNode('div', 'ux-inventory-metrics');
|
||||
metrics.append(
|
||||
createMetric('unassigned', 'Ohne Lagerort', 'Diese Artikel zuerst zuweisen'),
|
||||
createMetric('locations', 'Lagerorte', 'Strukturierte Ablageplätze'),
|
||||
createMetric('active', 'Aktiv', 'Aktuell im Verkaufsbestand'),
|
||||
createMetric('value', 'Lagerwert', 'Summe der aktiven Artikel')
|
||||
);
|
||||
overview.append(head, metrics);
|
||||
header.after(overview);
|
||||
stats.classList.add('ux-inventory-stats-secondary');
|
||||
|
||||
next.addEventListener('click', () => {
|
||||
const target = root.querySelector('#inv-locations .inv-unassigned:not(.ux-inventory-filtered-out)')
|
||||
|| root.querySelector('#inv-locations .inv-location-card:not(.ux-inventory-filtered-out)');
|
||||
if (!target) return;
|
||||
if (target.classList.contains('inv-unassigned')) target.click();
|
||||
else {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createLocationToolbar(root) {
|
||||
const locations = root.querySelector('#inv-locations');
|
||||
if (!locations || root.querySelector('.ux-inventory-toolbar')) return;
|
||||
|
||||
const toolbar = createNode('section', 'ux-inventory-toolbar card');
|
||||
const primary = createNode('div', 'ux-inventory-toolbar-primary');
|
||||
const searchLabel = createNode('label', 'ux-inventory-search');
|
||||
searchLabel.append(createNode('span', '', 'Lagerort suchen'));
|
||||
const search = createNode('input');
|
||||
search.type = 'search';
|
||||
search.placeholder = 'Regal, Karton oder Schrank …';
|
||||
search.dataset.uxInventorySearch = 'true';
|
||||
searchLabel.append(search);
|
||||
const count = createNode('span', 'ux-inventory-visible-count', '0 Lagerorte');
|
||||
count.setAttribute('aria-live', 'polite');
|
||||
primary.append(searchLabel, count);
|
||||
|
||||
const controls = createNode('div', 'ux-inventory-controls');
|
||||
const filters = createNode('div', 'ux-inventory-filter-group');
|
||||
filters.setAttribute('role', 'group');
|
||||
filters.setAttribute('aria-label', 'Lagerorte filtern');
|
||||
[['all', 'Alle'], ['assigned', 'Zugewiesen'], ['unassigned', 'Ohne Lagerort']].forEach(([value, label]) => {
|
||||
const button = createNode('button', 'ux-inventory-filter', label);
|
||||
button.type = 'button';
|
||||
button.dataset.uxInventoryFilter = value;
|
||||
button.setAttribute('aria-pressed', value === 'all' ? 'true' : 'false');
|
||||
filters.append(button);
|
||||
});
|
||||
|
||||
const sortLabel = createNode('label', 'ux-inventory-sort');
|
||||
sortLabel.append(createNode('span', '', 'Sortierung'));
|
||||
const sort = createNode('select');
|
||||
sort.dataset.uxInventorySort = 'true';
|
||||
[['attention', 'Aufmerksamkeit zuerst'], ['name', 'Name A–Z'], ['items', 'Meiste Artikel'], ['value', 'Höchster Wert']]
|
||||
.forEach(([value, label]) => {
|
||||
const option = createNode('option', '', label);
|
||||
option.value = value;
|
||||
sort.append(option);
|
||||
});
|
||||
try { sort.value = localStorage.getItem(SORT_KEY) || 'attention'; } catch {}
|
||||
sortLabel.append(sort);
|
||||
controls.append(filters, sortLabel);
|
||||
toolbar.append(primary, controls);
|
||||
locations.before(toolbar);
|
||||
}
|
||||
|
||||
function enhanceLocationCards(root) {
|
||||
root.querySelectorAll('#inv-locations .inv-location-card').forEach(card => {
|
||||
const meta = locationMeta(card);
|
||||
card.dataset.uxInventoryKind = meta.unassigned ? 'unassigned' : 'assigned';
|
||||
card.dataset.uxInventoryCount = String(meta.count);
|
||||
card.dataset.uxInventoryValue = String(meta.value);
|
||||
card.dataset.uxInventoryName = normalize(meta.name);
|
||||
card.classList.toggle('ux-inventory-needs-attention', meta.unassigned && meta.count > 0);
|
||||
card.setAttribute('role', 'button');
|
||||
card.tabIndex = 0;
|
||||
card.setAttribute('aria-label', `${meta.name || 'Lagerort'} öffnen, ${meta.count} Artikel`);
|
||||
if (card.dataset.uxInventoryKeyboard === 'true') return;
|
||||
card.dataset.uxInventoryKeyboard = 'true';
|
||||
card.addEventListener('keydown', event => {
|
||||
if (!['Enter', ' '].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
card.click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sortLocationCards(root) {
|
||||
const container = root.querySelector('#inv-locations');
|
||||
if (!container) return;
|
||||
const mode = root.querySelector('[data-ux-inventory-sort]')?.value || 'attention';
|
||||
const current = [...container.querySelectorAll('.inv-location-card')];
|
||||
const sorted = [...current].sort((a, b) => {
|
||||
const left = locationMeta(a);
|
||||
const right = locationMeta(b);
|
||||
if (mode === 'name') return left.name.localeCompare(right.name, 'de');
|
||||
if (mode === 'items') return right.count - left.count || left.name.localeCompare(right.name, 'de');
|
||||
if (mode === 'value') return right.value - left.value || left.name.localeCompare(right.name, 'de');
|
||||
if (left.unassigned !== right.unassigned) return left.unassigned ? -1 : 1;
|
||||
return right.count - left.count || left.name.localeCompare(right.name, 'de');
|
||||
});
|
||||
if (!sorted.some((card, index) => current[index] !== card)) return;
|
||||
sorted.forEach(card => container.append(card));
|
||||
}
|
||||
|
||||
function applyLocationFilters(root) {
|
||||
const query = normalize(root.querySelector('[data-ux-inventory-search]')?.value);
|
||||
const filter = root.querySelector('[data-ux-inventory-filter][aria-pressed="true"]')?.dataset.uxInventoryFilter || 'all';
|
||||
let visible = 0;
|
||||
root.querySelectorAll('#inv-locations .inv-location-card').forEach(card => {
|
||||
const meta = locationMeta(card);
|
||||
const show = (!query || normalize(meta.name).includes(query))
|
||||
&& (filter === 'all' || (filter === 'assigned' && !meta.unassigned) || (filter === 'unassigned' && meta.unassigned));
|
||||
card.classList.toggle('ux-inventory-filtered-out', !show);
|
||||
if (show) visible += 1;
|
||||
});
|
||||
const count = root.querySelector('.ux-inventory-visible-count');
|
||||
if (count) count.textContent = `${visible} ${visible === 1 ? 'Lagerort' : 'Lagerorte'}`;
|
||||
}
|
||||
|
||||
function updateOverview(root) {
|
||||
const unassignedCard = root.querySelector('#inv-locations .inv-unassigned');
|
||||
const unassigned = unassignedCard ? locationMeta(unassignedCard).count : 0;
|
||||
const values = {
|
||||
unassigned: String(unassigned),
|
||||
locations: readStat(root, 'Lagerorte'),
|
||||
active: readStat(root, 'Aktive Artikel'),
|
||||
value: readStat(root, 'Lagerwert'),
|
||||
};
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
const node = root.querySelector(`[data-ux-inventory-metric="${key}"]`);
|
||||
if (node && node.textContent !== value) node.textContent = value;
|
||||
});
|
||||
const next = root.querySelector('[data-ux-inventory-next]');
|
||||
if (next) {
|
||||
const label = unassigned > 0 ? `${unassigned} Artikel ohne Lagerort zuweisen` : 'Lagerorte durchsuchen';
|
||||
if (next.textContent !== label) next.textContent = label;
|
||||
next.dataset.uxInventoryNext = unassigned > 0 ? 'unassigned' : 'overview';
|
||||
}
|
||||
root.classList.toggle('ux-inventory-has-unassigned', unassigned > 0);
|
||||
}
|
||||
|
||||
function createDetailWorkspace(root) {
|
||||
const detail = root.querySelector('#inv-detail');
|
||||
const list = root.querySelector('#inv-detail-list');
|
||||
const actions = detail?.querySelector('.inventory-detail-actions');
|
||||
if (!detail || !list || !actions || detail.dataset.uxInventoryStructured === 'true') return;
|
||||
|
||||
const head = createNode('div', 'ux-inventory-detail-head');
|
||||
const titleWrap = createNode('div', 'ux-inventory-detail-title-wrap');
|
||||
const back = detail.querySelector('#inv-back');
|
||||
const title = detail.querySelector('#inv-detail-title');
|
||||
if (back) titleWrap.append(back);
|
||||
if (title) titleWrap.append(title);
|
||||
|
||||
const more = createNode('details', 'ux-inventory-more-actions');
|
||||
const moreBody = createNode('div', 'ux-inventory-more-actions-body');
|
||||
const rename = detail.querySelector('#inv-rename-btn');
|
||||
if (rename) moreBody.append(rename);
|
||||
more.append(createNode('summary', '', 'Lagerort-Aktionen'), moreBody);
|
||||
head.append(titleWrap, more);
|
||||
|
||||
const toolbar = createNode('div', 'ux-inventory-detail-toolbar');
|
||||
const searchLabel = createNode('label', 'ux-inventory-detail-search');
|
||||
searchLabel.append(createNode('span', '', 'Artikel suchen'));
|
||||
const search = createNode('input');
|
||||
search.type = 'search';
|
||||
search.placeholder = 'Titel, SKU, Plattform oder Status …';
|
||||
search.dataset.uxInventoryDetailSearch = 'true';
|
||||
searchLabel.append(search);
|
||||
const selectLabel = createNode('label', 'ux-inventory-select-all');
|
||||
const selectAll = createNode('input');
|
||||
selectAll.type = 'checkbox';
|
||||
selectAll.dataset.uxInventorySelectAll = 'true';
|
||||
selectLabel.append(selectAll, createNode('span', '', 'Sichtbare auswählen'));
|
||||
toolbar.append(searchLabel, selectLabel);
|
||||
|
||||
const selection = createNode('div', 'ux-inventory-selection-bar');
|
||||
selection.setAttribute('aria-live', 'polite');
|
||||
const selectedCount = createNode('strong', 'ux-inventory-selected-count', '0 ausgewählt');
|
||||
const move = detail.querySelector('#inv-move-btn');
|
||||
selection.append(selectedCount);
|
||||
if (move) selection.append(move);
|
||||
|
||||
actions.remove();
|
||||
detail.prepend(head);
|
||||
list.before(toolbar, selection);
|
||||
detail.dataset.uxInventoryStructured = 'true';
|
||||
|
||||
search.addEventListener('input', () => applyDetailFilter(root));
|
||||
selectAll.addEventListener('change', () => {
|
||||
root.querySelectorAll('#inv-detail-list .history-item:not(.ux-inventory-detail-filtered) .inv-item-check').forEach(checkbox => {
|
||||
checkbox.checked = selectAll.checked;
|
||||
checkbox.closest('.history-item')?.classList.toggle('ux-inventory-selected', checkbox.checked);
|
||||
});
|
||||
updateDetailSelection(root);
|
||||
});
|
||||
}
|
||||
|
||||
function enhanceDetailRows(root) {
|
||||
root.querySelectorAll('#inv-detail-list .history-item').forEach(row => {
|
||||
const checkbox = row.querySelector('.inv-item-check');
|
||||
const status = row.querySelector('.status-dot');
|
||||
if (status && row.dataset.uxInventoryStatusReady !== 'true') {
|
||||
const raw = normalize(status.textContent);
|
||||
status.textContent = raw === 'sold' ? 'Verkauft' : raw === 'reserved' ? 'Reserviert' : 'Aktiv';
|
||||
row.dataset.uxInventoryStatus = raw || 'active';
|
||||
row.dataset.uxInventoryStatusReady = 'true';
|
||||
}
|
||||
if (row.dataset.uxInventoryBound === 'true') return;
|
||||
row.dataset.uxInventoryBound = 'true';
|
||||
checkbox?.addEventListener('change', () => {
|
||||
row.classList.toggle('ux-inventory-selected', checkbox.checked);
|
||||
updateDetailSelection(root);
|
||||
});
|
||||
row.addEventListener('click', event => {
|
||||
if (event.target.closest('input,button,a,select') || !checkbox) return;
|
||||
checkbox.checked = !checkbox.checked;
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
applyDetailFilter(root);
|
||||
updateDetailSelection(root);
|
||||
}
|
||||
|
||||
function applyDetailFilter(root) {
|
||||
const query = normalize(root.querySelector('[data-ux-inventory-detail-search]')?.value);
|
||||
root.querySelectorAll('#inv-detail-list .history-item').forEach(row => {
|
||||
row.classList.toggle('ux-inventory-detail-filtered', Boolean(query) && !normalize(row.textContent).includes(query));
|
||||
});
|
||||
updateDetailSelection(root);
|
||||
}
|
||||
|
||||
function updateDetailSelection(root) {
|
||||
const visible = [...root.querySelectorAll('#inv-detail-list .history-item:not(.ux-inventory-detail-filtered) .inv-item-check')];
|
||||
const selected = [...root.querySelectorAll('#inv-detail-list .inv-item-check:checked')];
|
||||
const count = root.querySelector('.ux-inventory-selected-count');
|
||||
if (count) count.textContent = `${selected.length} ausgewählt`;
|
||||
root.querySelector('.ux-inventory-selection-bar')?.classList.toggle('has-selection', selected.length > 0);
|
||||
const move = root.querySelector('#inv-move-btn');
|
||||
if (move) move.disabled = selected.length === 0;
|
||||
const selectAll = root.querySelector('[data-ux-inventory-select-all]');
|
||||
if (selectAll) {
|
||||
const selectedVisible = visible.filter(item => item.checked).length;
|
||||
selectAll.checked = visible.length > 0 && selectedVisible === visible.length;
|
||||
selectAll.indeterminate = selectedVisible > 0 && selectedVisible < visible.length;
|
||||
}
|
||||
}
|
||||
|
||||
function bindToolbar(root) {
|
||||
root.querySelector('[data-ux-inventory-search]')?.addEventListener('input', () => applyLocationFilters(root));
|
||||
root.querySelectorAll('[data-ux-inventory-filter]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
root.querySelectorAll('[data-ux-inventory-filter]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
|
||||
applyLocationFilters(root);
|
||||
});
|
||||
button.addEventListener('keydown', event => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const buttons = [...root.querySelectorAll('[data-ux-inventory-filter]')];
|
||||
const index = buttons.indexOf(button);
|
||||
const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1
|
||||
: event.key === 'ArrowRight' ? (index + 1) % buttons.length : (index - 1 + buttons.length) % buttons.length;
|
||||
buttons[nextIndex]?.focus();
|
||||
});
|
||||
});
|
||||
root.querySelector('[data-ux-inventory-sort]')?.addEventListener('change', event => {
|
||||
try { localStorage.setItem(SORT_KEY, event.target.value); } catch {}
|
||||
sortLocationCards(root);
|
||||
applyLocationFilters(root);
|
||||
});
|
||||
}
|
||||
|
||||
function refresh(root) {
|
||||
enhanceLocationCards(root);
|
||||
sortLocationCards(root);
|
||||
applyLocationFilters(root);
|
||||
updateOverview(root);
|
||||
enhanceDetailRows(root);
|
||||
}
|
||||
|
||||
function observe(root) {
|
||||
let scheduled = false;
|
||||
const schedule = () => {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
refresh(root);
|
||||
});
|
||||
};
|
||||
const observer = new MutationObserver(schedule);
|
||||
[root.querySelector('#inv-stats'), root.querySelector('#inv-locations'), root.querySelector('#inv-detail-list')]
|
||||
.filter(Boolean)
|
||||
.forEach(node => observer.observe(node, { subtree: true, childList: true, characterData: true }));
|
||||
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
|
||||
}
|
||||
|
||||
function init() {
|
||||
const root = document.getElementById(ROOT_ID);
|
||||
if (!root || root.dataset.uxInventoryReady === 'true') return;
|
||||
root.dataset.uxInventoryReady = 'true';
|
||||
root.classList.add('ux-inventory-ready');
|
||||
createOverview(root);
|
||||
createLocationToolbar(root);
|
||||
createDetailWorkspace(root);
|
||||
bindToolbar(root);
|
||||
refresh(root);
|
||||
observe(root);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
else init();
|
||||
})();
|
||||
+1
-1
@@ -106,6 +106,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/login.js?v=1.41.2" defer></script>
|
||||
<script src="/login.js?v=1.43.0" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
/* Vendoo 1.44.0 — additive media library UX layer */
|
||||
|
||||
#media-library.media-library-page {
|
||||
--ux-media-card-min: 210px;
|
||||
--ux-media-image-ratio: 4 / 3;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#media-library[data-media-density="compact"] {
|
||||
--ux-media-card-min: 168px;
|
||||
--ux-media-image-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
#media-library[data-media-density="large"] {
|
||||
--ux-media-card-min: 280px;
|
||||
--ux-media-image-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.ux-media-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin: 18px 0 12px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--vd-border, rgba(148, 163, 184, .24));
|
||||
border-radius: var(--vd-radius-lg, 18px);
|
||||
background: var(--vd-surface, #fff);
|
||||
box-shadow: var(--vd-shadow-sm, 0 10px 30px rgba(15, 23, 42, .06));
|
||||
}
|
||||
|
||||
.ux-media-command-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ux-media-command-copy > span {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: var(--vd-text-muted, #64748b);
|
||||
font-size: .75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ux-media-command-copy strong {
|
||||
display: block;
|
||||
color: var(--vd-text, #0f172a);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ux-media-command-copy small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--vd-text-muted, #64748b);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ux-media-density {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--vd-border, rgba(148, 163, 184, .24));
|
||||
border-radius: 12px;
|
||||
background: var(--vd-surface-muted, rgba(148, 163, 184, .09));
|
||||
}
|
||||
|
||||
.ux-media-density button {
|
||||
min-width: 38px;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--vd-text-muted, #64748b);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: .78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ux-media-density button.is-active {
|
||||
background: var(--vd-surface, #fff);
|
||||
color: var(--vd-accent, #2563eb);
|
||||
box-shadow: 0 2px 10px rgba(15, 23, 42, .09);
|
||||
}
|
||||
|
||||
#media-library .media-summary {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 3px 2px 8px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
#media-library .media-summary > button {
|
||||
flex: 0 0 auto;
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
#media-library .media-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vd-border, rgba(148, 163, 184, .24));
|
||||
border-radius: var(--vd-radius-lg, 18px);
|
||||
background: var(--vd-surface, #fff);
|
||||
}
|
||||
|
||||
#media-library .media-search {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ux-media-filter-details {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ux-media-filter-details > summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--vd-border, rgba(148, 163, 184, .3));
|
||||
border-radius: 11px;
|
||||
color: var(--vd-text, #0f172a);
|
||||
cursor: pointer;
|
||||
font-size: .86rem;
|
||||
font-weight: 700;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ux-media-filter-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-media-filter-details > summary::after {
|
||||
content: "⌄";
|
||||
color: var(--vd-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.ux-media-filter-details[open] > summary,
|
||||
.ux-media-filter-details > summary:hover {
|
||||
border-color: color-mix(in srgb, var(--vd-accent, #2563eb) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--vd-accent, #2563eb) 7%, transparent);
|
||||
}
|
||||
|
||||
.ux-media-filter-panel {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
width: min(350px, calc(100vw - 40px));
|
||||
padding: 15px;
|
||||
border: 1px solid var(--vd-border, rgba(148, 163, 184, .28));
|
||||
border-radius: 14px;
|
||||
background: var(--vd-surface, #fff);
|
||||
box-shadow: var(--vd-shadow-lg, 0 22px 55px rgba(15, 23, 42, .18));
|
||||
}
|
||||
|
||||
.ux-media-filter-panel > label,
|
||||
.ux-media-filter-panel > .ux-media-filter-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--vd-text-muted, #64748b);
|
||||
font-size: .78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ux-media-filter-panel select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ux-media-filter-panel .media-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 40px;
|
||||
padding: 0 11px;
|
||||
border-radius: 10px;
|
||||
background: var(--vd-surface-muted, rgba(148, 163, 184, .09));
|
||||
color: var(--vd-text, #0f172a);
|
||||
}
|
||||
|
||||
.ux-media-active-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
min-height: 0;
|
||||
margin: 9px 0 0;
|
||||
}
|
||||
|
||||
.ux-media-active-filters.is-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-media-active-filters > strong {
|
||||
color: var(--vd-text-muted, #64748b);
|
||||
font-size: .76rem;
|
||||
}
|
||||
|
||||
.ux-media-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
padding: 0 5px 0 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--vd-accent, #2563eb) 24%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--vd-accent, #2563eb) 7%, transparent);
|
||||
color: var(--vd-text, #0f172a);
|
||||
font-size: .77rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ux-media-filter-chip button,
|
||||
.ux-media-filter-reset {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ux-media-filter-chip button {
|
||||
display: grid;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ux-media-filter-chip button:hover {
|
||||
background: rgba(15, 23, 42, .09);
|
||||
}
|
||||
|
||||
.ux-media-filter-reset {
|
||||
color: var(--vd-accent, #2563eb);
|
||||
font-size: .77rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#media-library .media-batchbar {
|
||||
display: none;
|
||||
position: sticky;
|
||||
z-index: 28;
|
||||
top: 72px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--vd-accent, #2563eb) 30%, transparent);
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--vd-surface, #fff) 94%, var(--vd-accent, #2563eb));
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, .12);
|
||||
}
|
||||
|
||||
#media-library[data-has-media-selection] .media-batchbar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ux-media-batch-primary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.ux-media-batch-more {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ux-media-batch-more > summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--vd-border, rgba(148, 163, 184, .28));
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-size: .78rem;
|
||||
font-weight: 700;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ux-media-batch-more > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ux-media-batch-secondary {
|
||||
position: absolute;
|
||||
z-index: 42;
|
||||
top: calc(100% + 7px);
|
||||
right: 0;
|
||||
min-width: 190px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--vd-border, rgba(148, 163, 184, .28));
|
||||
border-radius: 12px;
|
||||
background: var(--vd-surface, #fff);
|
||||
box-shadow: var(--vd-shadow-lg, 0 20px 45px rgba(15, 23, 42, .18));
|
||||
}
|
||||
|
||||
.ux-media-batch-secondary button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#media-library .media-pagination-top.is-redundant {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#media-library .media-pagination {
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
#media-library .media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--ux-media-card-min)), 1fr));
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
#media-library .media-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vd-border, rgba(148, 163, 184, .24));
|
||||
border-radius: 15px;
|
||||
background: var(--vd-surface, #fff);
|
||||
box-shadow: 0 5px 18px rgba(15, 23, 42, .055);
|
||||
transition: border-color .16s ease, box-shadow .16s ease, transform .16s ease;
|
||||
}
|
||||
|
||||
#media-library .media-card:hover,
|
||||
#media-library .media-card:focus-within {
|
||||
border-color: color-mix(in srgb, var(--vd-accent, #2563eb) 38%, transparent);
|
||||
box-shadow: 0 14px 34px rgba(15, 23, 42, .12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
#media-library .media-card.is-selected {
|
||||
border-color: var(--vd-accent, #2563eb);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--vd-accent, #2563eb) 20%, transparent), 0 14px 34px rgba(15, 23, 42, .12);
|
||||
}
|
||||
|
||||
#media-library .media-card-image {
|
||||
position: relative;
|
||||
aspect-ratio: var(--ux-media-image-ratio);
|
||||
overflow: hidden;
|
||||
background: var(--vd-surface-muted, #eef2f7);
|
||||
}
|
||||
|
||||
#media-library .media-card-open {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
#media-library .media-card-open img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform .25s ease;
|
||||
}
|
||||
|
||||
#media-library .media-card:hover .media-card-open img,
|
||||
#media-library .media-card:focus-within .media-card-open img {
|
||||
transform: scale(1.025);
|
||||
}
|
||||
|
||||
#media-library .media-open-indicator {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, .7);
|
||||
border-radius: 50%;
|
||||
background: rgba(15, 23, 42, .72);
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity .16s ease, transform .16s ease;
|
||||
}
|
||||
|
||||
#media-library .media-card:hover .media-open-indicator,
|
||||
#media-library .media-card:focus-within .media-open-indicator {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
#media-library .media-card-select,
|
||||
#media-library .media-card-favorite-toggle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, .16);
|
||||
}
|
||||
|
||||
#media-library .media-card-select {
|
||||
top: 9px;
|
||||
left: 9px;
|
||||
}
|
||||
|
||||
#media-library .media-card-favorite-toggle {
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
#media-library .media-card-content {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 12px 12px 9px;
|
||||
}
|
||||
|
||||
#media-library .media-card-content > strong {
|
||||
overflow: hidden;
|
||||
color: var(--vd-text, #0f172a);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#media-library .media-card-content > span,
|
||||
#media-library .media-card-content > small {
|
||||
overflow: hidden;
|
||||
color: var(--vd-text-muted, #64748b);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#media-library .media-card-sources {
|
||||
display: flex;
|
||||
min-height: 21px;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#media-library .media-card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
padding: 6px;
|
||||
border-top: 1px solid var(--vd-border, rgba(148, 163, 184, .18));
|
||||
background: var(--vd-surface-muted, rgba(148, 163, 184, .055));
|
||||
}
|
||||
|
||||
#media-library .media-action {
|
||||
display: inline-grid;
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
#media-library .media-action-label {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0 0 0 0) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
#media-library .media-grid.is-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#media-library .media-grid.is-list .media-card {
|
||||
display: grid;
|
||||
grid-template-columns: 116px minmax(0, 1fr) auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#media-library .media-grid.is-list .media-card-image {
|
||||
aspect-ratio: 4 / 3;
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
#media-library .media-grid.is-list .media-card-actions {
|
||||
grid-template-columns: repeat(2, 42px);
|
||||
align-content: center;
|
||||
border-top: 0;
|
||||
border-left: 1px solid var(--vd-border, rgba(148, 163, 184, .18));
|
||||
}
|
||||
|
||||
.ux-media-lightbox-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ux-media-lightbox-position {
|
||||
min-width: 72px;
|
||||
color: var(--vd-text-muted, #64748b);
|
||||
font-size: .78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#media-lightbox .media-lightbox-dialog > header {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#media-lightbox .media-lightbox-body {
|
||||
min-height: min(68vh, 760px);
|
||||
}
|
||||
|
||||
#media-lightbox .media-lightbox-stage {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#media-lightbox .media-lightbox-dialog > footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#media-lightbox .ux-media-use {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
#media-lightbox .media-lightbox-meta-details {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
#media-lightbox .media-lightbox-meta-details > summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
#media-library .media-toolbar {
|
||||
grid-template-columns: minmax(220px, 1fr) auto;
|
||||
}
|
||||
|
||||
#media-library .media-view-switch {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
#media-lightbox .media-lightbox-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#media-lightbox .media-lightbox-meta-details {
|
||||
display: block;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
#media-lightbox .media-lightbox-meta-details > summary {
|
||||
display: block;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid var(--vd-border, rgba(148, 163, 184, .24));
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#media-lightbox .media-lightbox-meta-details > #media-lightbox-meta {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.ux-media-command,
|
||||
#media-library .media-toolbar,
|
||||
#media-library .media-batchbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ux-media-command {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ux-media-density {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
#media-library .media-toolbar {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
#media-library .media-search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ux-media-filter-details {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.ux-media-filter-panel {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#media-library .media-batchbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ux-media-batch-primary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ux-media-batch-primary button {
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
|
||||
#media-library .media-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
#media-library[data-media-density="large"] .media-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#media-library .media-card-content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#media-library .media-grid.is-list .media-card {
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
#media-library .media-grid.is-list .media-card-actions {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
border-top: 1px solid var(--vd-border, rgba(148, 163, 184, .18));
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
#media-lightbox .media-lightbox-toolbar {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ux-media-lightbox-nav {
|
||||
width: 100%;
|
||||
order: 2;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 470px) {
|
||||
#media-library .media-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#media-library .media-summary > button {
|
||||
min-width: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
#media-library .media-open-indicator {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#media-library .media-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#media-library .media-card,
|
||||
#media-library .media-card-open img,
|
||||
#media-library .media-open-indicator {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
#media-library button:focus-visible,
|
||||
#media-library a:focus-visible,
|
||||
#media-library input:focus-visible,
|
||||
#media-library select:focus-visible,
|
||||
#media-lightbox button:focus-visible,
|
||||
#media-lightbox a:focus-visible,
|
||||
#media-lightbox input:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--vd-accent, #2563eb) 38%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const DENSITY_KEY = 'vendoo_media_density_v1';
|
||||
const DENSITIES = new Set(['compact', 'comfortable', 'large']);
|
||||
let activePath = '';
|
||||
let refreshFrame = 0;
|
||||
let gridObserver = null;
|
||||
let countObserver = null;
|
||||
|
||||
function root() {
|
||||
return document.getElementById('media-library');
|
||||
}
|
||||
|
||||
function createElement(tag, className, text) {
|
||||
const element = document.createElement(tag);
|
||||
if (className) element.className = className;
|
||||
if (text !== undefined) element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
|
||||
function queueRefresh() {
|
||||
if (refreshFrame) return;
|
||||
refreshFrame = requestAnimationFrame(() => {
|
||||
refreshFrame = 0;
|
||||
refreshPresentation();
|
||||
});
|
||||
}
|
||||
|
||||
function savedDensity() {
|
||||
const value = localStorage.getItem(DENSITY_KEY) || 'comfortable';
|
||||
return DENSITIES.has(value) ? value : 'comfortable';
|
||||
}
|
||||
|
||||
function applyDensity(value) {
|
||||
const density = DENSITIES.has(value) ? value : 'comfortable';
|
||||
const section = root();
|
||||
if (!section) return;
|
||||
section.dataset.mediaDensity = density;
|
||||
localStorage.setItem(DENSITY_KEY, density);
|
||||
section.querySelectorAll('[data-media-density-option]').forEach(button => {
|
||||
const active = button.dataset.mediaDensityOption === density;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
}
|
||||
|
||||
function createCommandCenter() {
|
||||
const section = root();
|
||||
const summary = section?.querySelector('.media-summary');
|
||||
if (!section || !summary || section.querySelector('.ux-media-command')) return;
|
||||
|
||||
const command = createElement('section', 'ux-media-command');
|
||||
command.setAttribute('aria-label', 'Galerieansicht');
|
||||
const copy = createElement('div', 'ux-media-command-copy');
|
||||
const kicker = createElement('span', '', 'Arbeitsansicht');
|
||||
const title = createElement('strong', '', 'Bilder schnell finden, prüfen und weiterverwenden');
|
||||
const result = createElement('small', '', 'Die Galerie wird geladen …');
|
||||
result.id = 'ux-media-result-summary';
|
||||
copy.append(kicker, title, result);
|
||||
|
||||
const density = createElement('div', 'ux-media-density');
|
||||
density.setAttribute('role', 'group');
|
||||
density.setAttribute('aria-label', 'Kartengröße');
|
||||
const options = [
|
||||
['compact', 'Klein', 'Kompakte Karten'],
|
||||
['comfortable', 'Mittel', 'Normale Karten'],
|
||||
['large', 'Groß', 'Große Karten'],
|
||||
];
|
||||
for (const [value, label, ariaLabel] of options) {
|
||||
const button = createElement('button', '', label);
|
||||
button.type = 'button';
|
||||
button.dataset.mediaDensityOption = value;
|
||||
button.setAttribute('aria-label', ariaLabel);
|
||||
button.addEventListener('click', () => applyDensity(value));
|
||||
density.append(button);
|
||||
}
|
||||
command.append(copy, density);
|
||||
summary.before(command);
|
||||
applyDensity(savedDensity());
|
||||
}
|
||||
|
||||
function wrapSelect(select, labelText) {
|
||||
if (!select || select.closest('.ux-media-filter-field')) return null;
|
||||
const label = createElement('label', 'ux-media-filter-field');
|
||||
label.append(createElement('span', '', labelText), select);
|
||||
return label;
|
||||
}
|
||||
|
||||
function reorganizeToolbar() {
|
||||
const toolbar = root()?.querySelector('.media-toolbar');
|
||||
if (!toolbar || toolbar.querySelector('.ux-media-filter-details')) return;
|
||||
|
||||
const source = document.getElementById('media-source');
|
||||
const sort = document.getElementById('media-sort');
|
||||
const favorites = document.getElementById('media-favorites-only')?.closest('.media-check');
|
||||
const pageSize = document.getElementById('media-page-size');
|
||||
|
||||
const details = createElement('details', 'ux-media-filter-details');
|
||||
const summary = createElement('summary', '', 'Filter & Anzeige');
|
||||
const panel = createElement('div', 'ux-media-filter-panel');
|
||||
const sourceField = wrapSelect(source, 'Quelle');
|
||||
const sortField = wrapSelect(sort, 'Sortierung');
|
||||
const pageField = wrapSelect(pageSize, 'Bilder pro Seite');
|
||||
[sourceField, sortField, favorites, pageField].filter(Boolean).forEach(node => panel.append(node));
|
||||
details.append(summary, panel);
|
||||
|
||||
const viewSwitch = toolbar.querySelector('.media-view-switch');
|
||||
if (viewSwitch) toolbar.insertBefore(details, viewSwitch);
|
||||
else toolbar.append(details);
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
if (!details.open || details.contains(event.target)) return;
|
||||
details.open = false;
|
||||
});
|
||||
}
|
||||
|
||||
function createActiveFilters() {
|
||||
const toolbar = root()?.querySelector('.media-toolbar');
|
||||
if (!toolbar || document.getElementById('ux-media-active-filters')) return;
|
||||
const filters = createElement('div', 'ux-media-active-filters is-empty');
|
||||
filters.id = 'ux-media-active-filters';
|
||||
filters.setAttribute('aria-live', 'polite');
|
||||
toolbar.after(filters);
|
||||
}
|
||||
|
||||
function controlEvent(control) {
|
||||
return control?.tagName === 'INPUT' && control.type === 'search' ? 'input' : 'change';
|
||||
}
|
||||
|
||||
function resetControl(control, value) {
|
||||
if (!control) return;
|
||||
if (control.type === 'checkbox') control.checked = Boolean(value);
|
||||
else control.value = value;
|
||||
control.dispatchEvent(new Event(controlEvent(control), { bubbles: true }));
|
||||
queueRefresh();
|
||||
}
|
||||
|
||||
function filterChip(label, control, resetValue) {
|
||||
const chip = createElement('span', 'ux-media-filter-chip');
|
||||
chip.append(createElement('span', '', label));
|
||||
const clear = createElement('button', '', '×');
|
||||
clear.type = 'button';
|
||||
clear.setAttribute('aria-label', `${label} entfernen`);
|
||||
clear.addEventListener('click', () => resetControl(control, resetValue));
|
||||
chip.append(clear);
|
||||
return chip;
|
||||
}
|
||||
|
||||
function selectedText(select) {
|
||||
if (!select?.value) return '';
|
||||
return select.options[select.selectedIndex]?.textContent?.trim() || select.value;
|
||||
}
|
||||
|
||||
function updateActiveFilters() {
|
||||
const container = document.getElementById('ux-media-active-filters');
|
||||
if (!container) return;
|
||||
const search = document.getElementById('media-search');
|
||||
const source = document.getElementById('media-source');
|
||||
const favorites = document.getElementById('media-favorites-only');
|
||||
const chips = [];
|
||||
|
||||
if (search?.value.trim()) chips.push(filterChip(`Suche: ${search.value.trim()}`, search, ''));
|
||||
if (source?.value && source.value !== 'all') chips.push(filterChip(`Quelle: ${selectedText(source)}`, source, 'all'));
|
||||
if (favorites?.checked) chips.push(filterChip('Nur Favoriten', favorites, false));
|
||||
|
||||
container.replaceChildren();
|
||||
container.classList.toggle('is-empty', chips.length === 0);
|
||||
if (!chips.length) return;
|
||||
container.append(createElement('strong', '', 'Aktive Filter'), ...chips);
|
||||
const reset = createElement('button', 'ux-media-filter-reset', 'Alle entfernen');
|
||||
reset.type = 'button';
|
||||
reset.addEventListener('click', () => {
|
||||
resetControl(search, '');
|
||||
resetControl(source, 'all');
|
||||
resetControl(favorites, false);
|
||||
});
|
||||
container.append(reset);
|
||||
}
|
||||
|
||||
function enhanceBatchBar() {
|
||||
const bar = root()?.querySelector('.media-batchbar');
|
||||
if (!bar || bar.querySelector('.ux-media-batch-primary')) return;
|
||||
const actions = bar.querySelector(':scope > div');
|
||||
if (!actions) return;
|
||||
const primary = createElement('div', 'ux-media-batch-primary');
|
||||
const more = createElement('details', 'ux-media-batch-more');
|
||||
const summary = createElement('summary', '', 'Weitere Aktionen');
|
||||
const secondary = createElement('div', 'ux-media-batch-secondary');
|
||||
|
||||
for (const id of ['media-send-to-factory', 'media-download-selected']) {
|
||||
const button = document.getElementById(id);
|
||||
if (button) primary.append(button);
|
||||
}
|
||||
const remove = document.getElementById('media-delete-selected');
|
||||
if (remove) secondary.append(remove);
|
||||
more.append(summary, secondary);
|
||||
actions.replaceChildren(primary, more);
|
||||
}
|
||||
|
||||
function selectedCount() {
|
||||
const value = document.getElementById('media-selected-count')?.textContent || '0';
|
||||
return Number.parseInt(value, 10) || 0;
|
||||
}
|
||||
|
||||
function updateSelectionState() {
|
||||
const section = root();
|
||||
if (!section) return;
|
||||
const count = selectedCount();
|
||||
section.toggleAttribute('data-has-media-selection', count > 0);
|
||||
const more = section.querySelector('.ux-media-batch-more');
|
||||
if (!count && more) more.open = false;
|
||||
}
|
||||
|
||||
function annotateCards() {
|
||||
const cards = root()?.querySelectorAll('#media-grid .media-card') || [];
|
||||
cards.forEach(card => {
|
||||
const preview = card.querySelector('[data-media-preview]');
|
||||
const title = card.querySelector('.media-card-content > strong')?.textContent?.trim() || 'Bild';
|
||||
if (preview) {
|
||||
preview.setAttribute('title', `${title} groß öffnen`);
|
||||
preview.setAttribute('aria-label', `${title} in großer Vorschau öffnen`);
|
||||
}
|
||||
card.querySelectorAll('.media-action').forEach(action => {
|
||||
const label = action.querySelector('.media-action-label')?.textContent?.trim();
|
||||
if (label && !action.getAttribute('aria-label')) action.setAttribute('aria-label', label);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateResultSummary() {
|
||||
const target = document.getElementById('ux-media-result-summary');
|
||||
if (!target) return;
|
||||
const summary = root()?.querySelector('.media-pagination-summary')?.textContent?.trim();
|
||||
const view = document.getElementById('media-view-list')?.classList.contains('active') ? 'Listenansicht' : 'Rasteransicht';
|
||||
target.textContent = `${summary || '0 Bilder'} · ${view}`;
|
||||
}
|
||||
|
||||
function updatePaginationState() {
|
||||
const section = root();
|
||||
if (!section) return;
|
||||
const pageInfo = section.querySelector('[data-media-page-info]')?.textContent?.trim() || '';
|
||||
const onePage = /von\s+1\b/i.test(pageInfo);
|
||||
section.querySelector('.media-pagination-top')?.classList.toggle('is-redundant', onePage);
|
||||
}
|
||||
|
||||
function previewButtons() {
|
||||
return [...(root()?.querySelectorAll('[data-media-preview]') || [])];
|
||||
}
|
||||
|
||||
function currentPreviewIndex() {
|
||||
const buttons = previewButtons();
|
||||
return buttons.findIndex(button => button.dataset.mediaPreview === activePath);
|
||||
}
|
||||
|
||||
function navigatePreview(direction) {
|
||||
const buttons = previewButtons();
|
||||
if (!buttons.length) return;
|
||||
const current = currentPreviewIndex();
|
||||
const next = current < 0 ? 0 : (current + direction + buttons.length) % buttons.length;
|
||||
activePath = buttons[next].dataset.mediaPreview || '';
|
||||
buttons[next].click();
|
||||
queueRefresh();
|
||||
}
|
||||
|
||||
function updateLightboxNavigation() {
|
||||
const modal = document.getElementById('media-lightbox');
|
||||
const buttons = previewButtons();
|
||||
const index = currentPreviewIndex();
|
||||
const position = document.getElementById('ux-media-lightbox-position');
|
||||
if (position) position.textContent = index >= 0 ? `${index + 1} von ${buttons.length}` : `${buttons.length} Bilder`;
|
||||
modal?.querySelectorAll('[data-ux-media-preview-nav]').forEach(button => {
|
||||
button.disabled = buttons.length < 2;
|
||||
});
|
||||
}
|
||||
|
||||
function activeCard() {
|
||||
if (!activePath) return null;
|
||||
return previewButtons().find(button => button.dataset.mediaPreview === activePath)?.closest('.media-card') || null;
|
||||
}
|
||||
|
||||
function enhanceLightbox() {
|
||||
const modal = document.getElementById('media-lightbox');
|
||||
const dialog = modal?.querySelector('.media-lightbox-dialog');
|
||||
const header = dialog?.querySelector(':scope > header');
|
||||
const footer = dialog?.querySelector(':scope > footer');
|
||||
const body = dialog?.querySelector('.media-lightbox-body');
|
||||
const meta = document.getElementById('media-lightbox-meta');
|
||||
if (!modal || !dialog || !header || !footer || !body) return;
|
||||
|
||||
if (!header.querySelector('.ux-media-lightbox-nav')) {
|
||||
const nav = createElement('div', 'ux-media-lightbox-nav');
|
||||
const previous = createElement('button', 'icon-button', '‹');
|
||||
previous.type = 'button';
|
||||
previous.dataset.uxMediaPreviewNav = 'previous';
|
||||
previous.setAttribute('aria-label', 'Vorheriges Bild');
|
||||
previous.addEventListener('click', () => navigatePreview(-1));
|
||||
const position = createElement('span', 'ux-media-lightbox-position', '0 Bilder');
|
||||
position.id = 'ux-media-lightbox-position';
|
||||
const next = createElement('button', 'icon-button', '›');
|
||||
next.type = 'button';
|
||||
next.dataset.uxMediaPreviewNav = 'next';
|
||||
next.setAttribute('aria-label', 'Nächstes Bild');
|
||||
next.addEventListener('click', () => navigatePreview(1));
|
||||
nav.append(previous, position, next);
|
||||
header.insertBefore(nav, header.querySelector('.media-lightbox-close'));
|
||||
}
|
||||
|
||||
if (meta && !meta.closest('.media-lightbox-meta-details')) {
|
||||
const details = createElement('details', 'media-lightbox-meta-details');
|
||||
details.open = true;
|
||||
details.append(createElement('summary', '', 'Bildinformationen anzeigen'), meta);
|
||||
body.append(details);
|
||||
}
|
||||
|
||||
if (!document.getElementById('ux-media-lightbox-use')) {
|
||||
const use = createElement('button', 'vd-button primary ux-media-use', 'Im Artikel verwenden');
|
||||
use.type = 'button';
|
||||
use.id = 'ux-media-lightbox-use';
|
||||
use.addEventListener('click', () => activeCard()?.querySelector('[data-media-use]')?.click());
|
||||
footer.prepend(use);
|
||||
}
|
||||
}
|
||||
|
||||
function bindControls() {
|
||||
const section = root();
|
||||
if (!section || section.dataset.uxMediaBound === 'true') return;
|
||||
section.dataset.uxMediaBound = 'true';
|
||||
|
||||
for (const id of ['media-search', 'media-source', 'media-sort', 'media-favorites-only', 'media-page-size']) {
|
||||
const control = document.getElementById(id);
|
||||
control?.addEventListener(controlEvent(control), queueRefresh);
|
||||
}
|
||||
document.getElementById('media-view-grid')?.addEventListener('click', queueRefresh);
|
||||
document.getElementById('media-view-list')?.addEventListener('click', queueRefresh);
|
||||
|
||||
section.addEventListener('click', event => {
|
||||
const preview = event.target.closest?.('[data-media-preview]');
|
||||
if (preview) {
|
||||
activePath = preview.dataset.mediaPreview || '';
|
||||
queueRefresh();
|
||||
}
|
||||
if (event.target.closest?.('[data-media-source], [data-media-select], [data-media-favorite]')) queueRefresh();
|
||||
}, true);
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
const modal = document.getElementById('media-lightbox');
|
||||
if (!modal || modal.classList.contains('hidden')) return;
|
||||
if (event.target.closest('input, textarea, select')) return;
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
navigatePreview(-1);
|
||||
}
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
navigatePreview(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function observeDynamicContent() {
|
||||
const grid = document.getElementById('media-grid');
|
||||
const count = document.getElementById('media-selected-count');
|
||||
if (grid && !gridObserver) {
|
||||
gridObserver = new MutationObserver(queueRefresh);
|
||||
gridObserver.observe(grid, { childList: true, subtree: true });
|
||||
}
|
||||
if (count && !countObserver) {
|
||||
countObserver = new MutationObserver(queueRefresh);
|
||||
countObserver.observe(count, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
window.addEventListener('beforeunload', () => {
|
||||
gridObserver?.disconnect();
|
||||
countObserver?.disconnect();
|
||||
if (refreshFrame) cancelAnimationFrame(refreshFrame);
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
function refreshPresentation() {
|
||||
updateActiveFilters();
|
||||
updateSelectionState();
|
||||
annotateCards();
|
||||
updateResultSummary();
|
||||
updatePaginationState();
|
||||
updateLightboxNavigation();
|
||||
}
|
||||
|
||||
function initializeMediaUx() {
|
||||
if (!root() || root().dataset.uxMediaReady === 'true') return;
|
||||
root().dataset.uxMediaReady = 'true';
|
||||
createCommandCenter();
|
||||
reorganizeToolbar();
|
||||
createActiveFilters();
|
||||
enhanceBatchBar();
|
||||
enhanceLightbox();
|
||||
bindControls();
|
||||
observeDynamicContent();
|
||||
refreshPresentation();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeMediaUx, { once: true });
|
||||
} else {
|
||||
initializeMediaUx();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,26 @@
|
||||
#admin-modules-panel[data-ux-workspace-ready="1"],#extensions[data-ux-workspace-ready="1"]{--ux-workspace-gap:clamp(14px,2vw,24px)}
|
||||
.ux-module-command,.ux-extension-board{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--ux-workspace-gap);align-items:center;padding:clamp(18px,2.6vw,30px);margin:0 0 16px;border:1px solid var(--vd-border,#d8dee8);border-radius:var(--vd-radius-xl,20px);background:linear-gradient(135deg,var(--vd-surface,#fff),var(--vd-surface-subtle,#f6f8fb));box-shadow:var(--vd-shadow-sm,0 8px 24px rgba(15,23,42,.06))}
|
||||
.ux-module-command-copy h3,.ux-extension-board h3{margin:4px 0 6px;font-size:clamp(1.2rem,2vw,1.65rem)}
|
||||
.ux-module-command-copy p,.ux-extension-board p{margin:0;max-width:72ch;color:var(--vd-text-muted,#667085)}
|
||||
.ux-module-kicker{display:block;color:var(--vd-accent,#ff6b2c);font-size:.75rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
|
||||
.ux-module-health{display:grid;grid-template-columns:repeat(4,minmax(74px,1fr));gap:8px;min-width:min(100%,390px)}
|
||||
.ux-module-health-item{display:grid;gap:2px;padding:12px 14px;border:1px solid var(--vd-border,#d8dee8);border-radius:14px;background:var(--vd-surface,#fff);text-align:center}
|
||||
.ux-module-health-item small{color:var(--vd-text-muted,#667085)}.ux-module-health-item strong{font-size:1.2rem}.ux-module-health-item[data-tone="danger"]{border-color:var(--vd-danger,#d92d20);background:color-mix(in srgb,var(--vd-danger,#d92d20) 8%,var(--vd-surface,#fff))}
|
||||
.ux-module-filterbar{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 14px;padding:6px;border:1px solid var(--vd-border,#d8dee8);border-radius:16px;background:var(--vd-surface-subtle,#f6f8fb)}
|
||||
.ux-module-filter{border:0;border-radius:11px;background:transparent;color:var(--vd-text-muted,#667085);padding:9px 13px;font-weight:700;cursor:pointer}.ux-module-filter[aria-pressed="true"]{background:var(--vd-surface,#fff);color:var(--vd-text,#101828);box-shadow:var(--vd-shadow-xs,0 2px 8px rgba(15,23,42,.08))}
|
||||
.ux-module-tools{display:grid;grid-template-columns:minmax(220px,1fr) auto minmax(250px,auto);gap:10px;align-items:start;margin-bottom:18px}.ux-module-tools>input{min-height:42px}
|
||||
.ux-module-install{border:1px solid var(--vd-border,#d8dee8);border-radius:14px;background:var(--vd-surface,#fff)}.ux-module-install summary{display:flex;justify-content:space-between;gap:16px;align-items:center;padding:11px 14px;cursor:pointer}.ux-module-install summary small{color:var(--vd-text-muted,#667085)}.ux-module-install-body{display:flex;gap:10px;align-items:center;padding:0 14px 14px}.ux-module-install-body input{min-width:0;max-width:320px}
|
||||
#admin-modules-panel[data-ux-workspace-ready="1"] #module-manager-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||
#admin-modules-panel[data-ux-workspace-ready="1"] .module-card{height:100%;outline:none;transition:transform .18s ease,border-color .18s ease,box-shadow .18s ease}
|
||||
#admin-modules-panel[data-ux-workspace-ready="1"] .module-card:hover{transform:translateY(-2px)}
|
||||
#admin-modules-panel[data-ux-workspace-ready="1"] .module-card:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,var(--vd-focus,#2563eb) 30%,transparent);border-color:var(--vd-focus,#2563eb)}
|
||||
#admin-modules-panel[data-ux-workspace-ready="1"] .module-card-actions{margin-top:auto;display:flex;gap:8px;flex-wrap:wrap}
|
||||
#admin-modules-panel[data-ux-workspace-ready="1"] .module-error{order:-1}
|
||||
.ux-extension-board{margin-top:16px}.ux-extension-status{display:flex;gap:10px;align-items:center;justify-content:flex-end;flex-wrap:wrap}.ux-extension-status #extensions-active-browser{margin:0}
|
||||
#extensions[data-ux-workspace-ready="1"] #extensions-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||
#extensions[data-ux-workspace-ready="1"] #extensions-grid>*{height:100%}
|
||||
@media (max-width:1100px){.ux-module-command,.ux-extension-board{grid-template-columns:1fr}.ux-module-health{min-width:0}.ux-extension-status{justify-content:flex-start}.ux-module-tools{grid-template-columns:1fr auto}.ux-module-install{grid-column:1/-1}}
|
||||
@media (max-width:780px){#admin-modules-panel[data-ux-workspace-ready="1"] #module-manager-list,#extensions[data-ux-workspace-ready="1"] #extensions-grid{grid-template-columns:1fr}.ux-module-health{grid-template-columns:repeat(2,1fr)}.ux-module-tools{grid-template-columns:1fr}.ux-module-install{grid-column:auto}.ux-module-install-body{align-items:stretch;flex-direction:column}.ux-module-filterbar{overflow-x:auto;flex-wrap:nowrap}.ux-module-filter{white-space:nowrap}}
|
||||
@media (max-width:520px){.ux-module-command,.ux-extension-board{padding:16px;border-radius:16px}.ux-module-health{grid-template-columns:1fr 1fr}.ux-extension-status{align-items:stretch;flex-direction:column}.ux-extension-status>*{width:100%}}
|
||||
@media (prefers-reduced-motion:reduce){#admin-modules-panel[data-ux-workspace-ready="1"] .module-card{transition:none}#admin-modules-panel[data-ux-workspace-ready="1"] .module-card:hover{transform:none}}
|
||||
.ux-module-filter:focus-visible,.ux-module-install summary:focus-visible{outline:3px solid color-mix(in srgb,var(--vd-focus,#2563eb) 35%,transparent);outline-offset:2px}
|
||||
@@ -0,0 +1,268 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
function loadCommandPlatformAssets() {
|
||||
if (!document.querySelector('link[data-ux-command-platform]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/command-platform.css?v=1.44.0';
|
||||
stylesheet.setAttribute('data-ux-command-platform', 'style');
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
if (!document.querySelector('script[data-ux-command-platform]')) {
|
||||
const script = document.createElement('script');
|
||||
script.src = '/command-platform.js?v=1.44.0';
|
||||
script.async = false;
|
||||
script.setAttribute('data-ux-command-platform', 'script');
|
||||
document.body.append(script);
|
||||
}
|
||||
}
|
||||
|
||||
function loadActionRegistryAssets() {
|
||||
if (!document.querySelector('link[data-ux-action-registry]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/action-registry.css?v=1.44.0';
|
||||
stylesheet.setAttribute('data-ux-action-registry', 'style');
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
if (!document.querySelector('script[data-ux-action-registry]')) {
|
||||
const script = document.createElement('script');
|
||||
script.src = '/action-registry.js?v=1.44.0';
|
||||
script.async = false;
|
||||
script.setAttribute('data-ux-action-registry', 'script');
|
||||
document.body.append(script);
|
||||
}
|
||||
}
|
||||
|
||||
function loadGlobalNavigationAssets() {
|
||||
if (!document.querySelector('link[data-ux-global-navigation]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/global-navigation-ux.css?v=1.44.0';
|
||||
stylesheet.setAttribute('data-ux-global-navigation', 'style');
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
if (!document.querySelector('script[data-ux-global-navigation]')) {
|
||||
const script = document.createElement('script');
|
||||
script.src = '/global-navigation-ux.js?v=1.44.0';
|
||||
script.async = false;
|
||||
script.setAttribute('data-ux-global-navigation', 'script');
|
||||
document.body.append(script);
|
||||
}
|
||||
}
|
||||
|
||||
function loadNavigationApi() {
|
||||
if (document.querySelector('script[data-ux-navigation-api]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = '/navigation-api.js?v=1.44.0';
|
||||
script.async = false;
|
||||
script.setAttribute('data-ux-navigation-api', 'script');
|
||||
document.body.append(script);
|
||||
}
|
||||
|
||||
function loadSafeActionAdapters() {
|
||||
if (document.querySelector('script[data-ux-safe-action-adapters]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = '/safe-action-adapters.js?v=1.44.0';
|
||||
script.async = false;
|
||||
script.setAttribute('data-ux-safe-action-adapters', 'script');
|
||||
document.body.append(script);
|
||||
}
|
||||
|
||||
loadCommandPlatformAssets();
|
||||
loadActionRegistryAssets();
|
||||
loadGlobalNavigationAssets();
|
||||
loadNavigationApi();
|
||||
loadSafeActionAdapters();
|
||||
|
||||
const moduleRoot = document.getElementById('admin-modules-panel');
|
||||
const extensionRoot = document.getElementById('extensions');
|
||||
if ((!moduleRoot && !extensionRoot) || document.documentElement.dataset.uxModuleExtensionReady === '1') return;
|
||||
document.documentElement.dataset.uxModuleExtensionReady = '1';
|
||||
|
||||
const storageKey = 'vendoo_module_extension_workspace_v1';
|
||||
const state = { moduleFilter: 'all', extensionFilter: 'all' };
|
||||
try { Object.assign(state, JSON.parse(localStorage.getItem(storageKey) || '{}')); } catch {}
|
||||
const persist = () => { try { localStorage.setItem(storageKey, JSON.stringify(state)); } catch {} };
|
||||
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
function makeFilter(label, value, group, active) {
|
||||
const button = el('button', 'ux-module-filter', label);
|
||||
button.type = 'button';
|
||||
button.dataset.uxModuleFilter = value;
|
||||
button.dataset.uxModuleGroup = group;
|
||||
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
return button;
|
||||
}
|
||||
|
||||
function text(node) {
|
||||
return String(node?.textContent || '').toLocaleLowerCase('de-DE');
|
||||
}
|
||||
|
||||
function setupModules() {
|
||||
if (!moduleRoot || moduleRoot.dataset.uxWorkspaceReady === '1') return;
|
||||
moduleRoot.dataset.uxWorkspaceReady = '1';
|
||||
|
||||
const summary = moduleRoot.querySelector('#module-manager-summary');
|
||||
const safety = moduleRoot.querySelector('#module-manager-safety');
|
||||
const list = moduleRoot.querySelector('#module-manager-list');
|
||||
const search = moduleRoot.querySelector('#module-manager-search');
|
||||
const file = moduleRoot.querySelector('#module-manager-file');
|
||||
const install = moduleRoot.querySelector('#module-manager-install');
|
||||
const refreshButton = moduleRoot.querySelector('#module-manager-refresh');
|
||||
if (!list) return;
|
||||
|
||||
const command = el('section', 'ux-module-command');
|
||||
command.setAttribute('aria-label', 'Modulverwaltung');
|
||||
const copy = el('div', 'ux-module-command-copy');
|
||||
copy.append(el('span', 'ux-module-kicker', 'Systemerweiterungen'), el('h3', '', 'Module kontrolliert verwalten'), el('p', '', 'Abhängigkeiten, Vertrauensstatus und Betriebszustand zuerst prüfen. Änderungen bleiben bewusste Einzelaktionen.'));
|
||||
const health = el('div', 'ux-module-health');
|
||||
command.append(copy, health);
|
||||
|
||||
const filters = el('div', 'ux-module-filterbar');
|
||||
[['Alle', 'all'], ['Aktiv', 'active'], ['Deaktiviert', 'disabled'], ['Installiert', 'external'], ['Probleme', 'problems']]
|
||||
.forEach(([label, value]) => filters.append(makeFilter(label, value, 'modules', state.moduleFilter === value)));
|
||||
|
||||
const installDetails = el('details', 'ux-module-install');
|
||||
const installSummary = el('summary');
|
||||
installSummary.append(el('strong', '', 'Modulpaket installieren'), el('small', '', 'Nur geprüfte .vmod-Pakete'));
|
||||
const installBody = el('div', 'ux-module-install-body');
|
||||
if (file) installBody.append(file);
|
||||
if (install) installBody.append(install);
|
||||
installDetails.append(installSummary, installBody);
|
||||
|
||||
const tools = el('div', 'ux-module-tools');
|
||||
if (search) tools.append(search);
|
||||
if (refreshButton) tools.append(refreshButton);
|
||||
tools.append(installDetails);
|
||||
|
||||
const anchor = summary || safety || list;
|
||||
anchor.insertAdjacentElement('beforebegin', command);
|
||||
command.insertAdjacentElement('afterend', filters);
|
||||
filters.insertAdjacentElement('afterend', tools);
|
||||
|
||||
function classify(card) {
|
||||
const value = text(card);
|
||||
return {
|
||||
active: value.includes('aktiv') && !value.includes('deaktiviert'),
|
||||
disabled: value.includes('deaktiviert') || card.classList.contains('is-disabled'),
|
||||
external: value.includes('installiert'),
|
||||
problems: value.includes('fehler') || value.includes('ungültig') || value.includes('invalid') || card.classList.contains('is-invalid') || Boolean(card.querySelector('.module-error')),
|
||||
};
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const cards = [...list.querySelectorAll('.module-card')];
|
||||
const counts = { total: cards.length, active: 0, disabled: 0, external: 0, problems: 0 };
|
||||
cards.forEach(card => {
|
||||
const flags = classify(card);
|
||||
Object.keys(flags).forEach(key => { if (flags[key]) counts[key] += 1; });
|
||||
const visible = state.moduleFilter === 'all' || flags[state.moduleFilter];
|
||||
card.hidden = !visible;
|
||||
card.tabIndex = visible ? 0 : -1;
|
||||
});
|
||||
health.replaceChildren();
|
||||
[['Gesamt', counts.total], ['Aktiv', counts.active], ['Installiert', counts.external], ['Probleme', counts.problems]]
|
||||
.forEach(([label, value]) => {
|
||||
const item = el('div', 'ux-module-health-item');
|
||||
item.append(el('small', '', label), el('strong', '', String(value)));
|
||||
if (label === 'Probleme' && value > 0) item.dataset.tone = 'danger';
|
||||
health.append(item);
|
||||
});
|
||||
filters.querySelectorAll('[data-ux-module-filter]').forEach(button => button.setAttribute('aria-pressed', button.dataset.uxModuleFilter === state.moduleFilter ? 'true' : 'false'));
|
||||
}
|
||||
|
||||
filters.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-ux-module-filter]');
|
||||
if (!button) return;
|
||||
state.moduleFilter = button.dataset.uxModuleFilter;
|
||||
persist();
|
||||
refresh();
|
||||
list.querySelector('.module-card:not([hidden])')?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
list.addEventListener('keydown', event => {
|
||||
const card = event.target.closest('.module-card');
|
||||
if (!card || !['Enter', ' '].includes(event.key)) return;
|
||||
if (event.target.closest('button, a, input, select')) return;
|
||||
event.preventDefault();
|
||||
card.querySelector('.module-card-actions button:not(:disabled), .module-card-actions a')?.focus();
|
||||
});
|
||||
|
||||
const observer = new MutationObserver(() => requestAnimationFrame(refresh));
|
||||
observer.observe(list, { childList: true });
|
||||
refresh();
|
||||
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
|
||||
}
|
||||
|
||||
function setupExtensions() {
|
||||
if (!extensionRoot || extensionRoot.dataset.uxWorkspaceReady === '1') return;
|
||||
extensionRoot.dataset.uxWorkspaceReady = '1';
|
||||
const grid = extensionRoot.querySelector('#extensions-grid');
|
||||
const activeBrowser = extensionRoot.querySelector('#extensions-active-browser');
|
||||
const prepare = extensionRoot.querySelector('#extensions-prepare-active');
|
||||
if (!grid) return;
|
||||
|
||||
const board = el('section', 'ux-extension-board');
|
||||
const copy = el('div');
|
||||
copy.append(el('span', 'ux-module-kicker', 'Browser-Verbindungen'), el('h3', '', 'Extensions vorbereiten und prüfen'), el('p', '', 'Vendoo bereitet Pakete und Pfade vor. Die endgültige Browserfreigabe bleibt immer manuell.'));
|
||||
const status = el('div', 'ux-extension-status');
|
||||
board.append(copy, status);
|
||||
|
||||
const filters = el('div', 'ux-module-filterbar ux-extension-filterbar');
|
||||
[['Alle', 'all'], ['Bereit', 'ready'], ['Verbunden', 'connected'], ['Aktion nötig', 'attention']]
|
||||
.forEach(([label, value]) => filters.append(makeFilter(label, value, 'extensions', state.extensionFilter === value)));
|
||||
|
||||
grid.insertAdjacentElement('beforebegin', filters);
|
||||
filters.insertAdjacentElement('beforebegin', board);
|
||||
if (activeBrowser) status.append(activeBrowser);
|
||||
if (prepare) status.append(prepare);
|
||||
|
||||
function classify(card) {
|
||||
const value = text(card);
|
||||
return {
|
||||
connected: value.includes('verbunden') || value.includes('aktiv'),
|
||||
ready: value.includes('bereit') || value.includes('vorbereitet') || value.includes('installiert'),
|
||||
attention: value.includes('nicht') || value.includes('fehler') || value.includes('aktion') || value.includes('installieren'),
|
||||
};
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const cards = [...grid.children].filter(node => node.nodeType === 1);
|
||||
let connected = 0;
|
||||
let attention = 0;
|
||||
cards.forEach(card => {
|
||||
const flags = classify(card);
|
||||
if (flags.connected) connected += 1;
|
||||
if (flags.attention) attention += 1;
|
||||
card.hidden = !(state.extensionFilter === 'all' || flags[state.extensionFilter]);
|
||||
});
|
||||
board.dataset.connected = String(connected);
|
||||
board.dataset.attention = String(attention);
|
||||
filters.querySelectorAll('[data-ux-module-filter]').forEach(button => button.setAttribute('aria-pressed', button.dataset.uxModuleFilter === state.extensionFilter ? 'true' : 'false'));
|
||||
}
|
||||
|
||||
filters.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-ux-module-filter]');
|
||||
if (!button) return;
|
||||
state.extensionFilter = button.dataset.uxModuleFilter;
|
||||
persist();
|
||||
refresh();
|
||||
});
|
||||
|
||||
const observer = new MutationObserver(() => requestAnimationFrame(refresh));
|
||||
observer.observe(grid, { childList: true, subtree: true, characterData: true });
|
||||
refresh();
|
||||
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
|
||||
}
|
||||
|
||||
setupModules();
|
||||
setupExtensions();
|
||||
})();
|
||||
@@ -150,6 +150,7 @@ async function perform(action, id) {
|
||||
await request('DELETE', `/api/admin/modules/${encodeURIComponent(id)}`);
|
||||
}
|
||||
await load();
|
||||
window.dispatchEvent(new CustomEvent('vendoo:modules-changed', { detail: { action, id } }));
|
||||
globalThis.toast?.(action === 'enable' ? 'Modul aktiviert.' : action === 'disable' ? 'Modul deaktiviert.' : 'Modul gelöscht.');
|
||||
} catch (error) {
|
||||
globalThis.toast?.(error.message);
|
||||
@@ -168,6 +169,7 @@ async function install() {
|
||||
await request('POST', '/api/admin/modules/install', form, { multipart: true });
|
||||
input.value = '';
|
||||
await load();
|
||||
window.dispatchEvent(new CustomEvent('vendoo:modules-changed', { detail: { action: 'install' } }));
|
||||
globalThis.toast?.('Modulpaket geprüft und installiert.');
|
||||
} catch (error) {
|
||||
globalThis.toast?.(error.message);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user