Compare commits

...
Author SHA1 Message Date
Masterluke77 d71f868cb2 docs(ci): define isolated UX platform diagnostics contract 2026-07-14 14:50:21 +02:00
Masterluke77 31e4df8bfd ci(ux): remove marketplace actions from isolated platform contract 2026-07-14 14:49:51 +02:00
Masterluke77 cacd16ea02 fix(ci): use YAML-safe block for UX platform preflight 2026-07-14 14:49:10 +02:00
Masterluke77 08409e61da ci(ux): isolate platform gates from npm installation and cache 2026-07-14 14:47:37 +02:00
Masterluke77 de47cc9c04 ci(ux): add independent Linux and Windows UX platform contract 2026-07-14 14:45:27 +02:00
Masterluke77 d10e54d842 ci(ux): add cross-platform UX gate runner with diagnostics 2026-07-14 14:45:19 +02:00
Masterluke77 b08fa1df0a ci(ux): run navigation and safe action gates before manifest contract 2026-07-14 14:44:36 +02:00
Masterluke77 ba59752e04 docs(navigation): record public workspace navigation API hardening 2026-07-14 14:43:22 +02:00
Masterluke77 aed0169b4b fix(deps): restore cookie-parser release contract version 2026-07-14 14:43:04 +02:00
Masterluke77 4a9d6e1b6b test(navigation): include public navigation API in release contract 2026-07-14 14:42:45 +02:00
Masterluke77 f6b0ae2a41 test(navigation): add public navigation API contract gate 2026-07-14 14:42:26 +02:00
Masterluke77 7ff428fbba feat(navigation): load public navigation API before safe action adapters 2026-07-14 14:42:07 +02:00
Masterluke77 52ba546656 feat(navigation): expose stable read-only workspace navigation API 2026-07-14 14:41:36 +02:00
Masterluke77 de1982c0dc docs(actions): document complete safe workspace action expansion 2026-07-14 14:40:48 +02:00
Masterluke77 52133e5d25 test(actions): expand safe action adapter runtime contract to 21 actions 2026-07-14 14:40:32 +02:00
Masterluke77 dfa06d3b8f feat(actions): complete safe workspace filter and view adapters 2026-07-14 14:39:59 +02:00
Masterluke77 dd9596ef85 docs(actions): record workspace scoping and local adapter verification 2026-07-14 14:18:30 +02:00
Masterluke77 d6f585ce13 test(actions): verify active workspace scoping for safe adapters 2026-07-14 14:16:25 +02:00
Masterluke77 e6e2ed70d5 fix(actions): scope safe adapters to the active Vendoo workspace 2026-07-14 14:15:59 +02:00
Masterluke77 9d8fb93175 docs(actions): document first safe workspace action migration 2026-07-14 14:15:25 +02:00
Masterluke77 b8478e2029 test(actions): include safe action adapters in release contract 2026-07-14 14:15:11 +02:00
Masterluke77 7dd0be2e8a test(actions): add safe action adapter acceptance gate 2026-07-14 14:14:53 +02:00
Masterluke77 76c8647d93 feat(actions): load safe action adapters after platform registries 2026-07-14 14:14:25 +02:00
Masterluke77 9100e018e2 feat(actions): migrate first safe workspace actions to registry adapters 2026-07-14 14:13:47 +02:00
Masterluke77 bd87f73999 docs(actions): document command favorite preservation hardening 2026-07-14 14:02:04 +02:00
Masterluke77 56c70877ae test(actions): guard command favorites during action state refresh 2026-07-14 13:59:53 +02:00
vendoo-release-bot 13c76534f0 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-14 11:59:47 +00:00
Masterluke77 ebb2849ad7 fix(actions): preserve action command favorites during state refresh 2026-07-14 13:59:32 +02:00
vendoo-release-bot bd6c68dee3 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-14 11:57:38 +00:00
Masterluke77 f13d046d82 test(actions): include universal action gate in release contract 2026-07-14 13:57:29 +02:00
vendoo-release-bot 315d64b284 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-14 11:57:21 +00:00
Masterluke77 e434a3d7d0 feat(actions): load action registry before global navigation 2026-07-14 13:57:12 +02:00
vendoo-release-bot 7af6fbf618 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-14 11:57:04 +00:00
Masterluke77 209d9f4140 docs(actions): document universal action registry contract 2026-07-14 13:56:48 +02:00
vendoo-release-bot 17df4a82c7 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-14 11:56:46 +00:00
Masterluke77 a12446a20b test(actions): add universal action registry acceptance gate 2026-07-14 13:56:35 +02:00
vendoo-release-bot 85f28e54de chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-14 11:56:30 +00:00
Masterluke77 847bb1669c feat(actions): add universal action surfaces and confirmation styles 2026-07-14 13:56:14 +02:00
Masterluke77 3b462ffff4 feat(actions): add universal action registry foundation 2026-07-14 13:56:01 +02:00
vendoo-release-bot 7bc8a81bba chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-14 11:26:30 +00:00
Masterluke77 09a2649c56 docs(ux): document command palette registry migration 2026-07-14 13:26:12 +02:00
Masterluke77 cb37893366 chore(ux): wire command palette registry gate into release contract 2026-07-14 13:25:59 +02:00
vendoo-release-bot 20d238a053 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-14 11:25:55 +00:00
Masterluke77 4418fae55a test(ux): add command palette registry acceptance gate 2026-07-14 13:25:39 +02:00
vendoo-release-bot 1fcd953336 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-14 11:25:25 +00:00
Masterluke77 2d4b5592f3 style(ux): refine registry-backed command palette 2026-07-14 13:25:18 +02:00
vendoo-release-bot 22fca8e23a chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-14 11:25:09 +00:00
Masterluke77 a606847229 feat(ux): migrate Ctrl+K palette to command registry 2026-07-14 13:24:55 +02:00
vendoo-release-bot c9f2d1248d chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-14 11:23:51 +00:00
Masterluke77 725d610875 feat(ux): extend command registry for palette migration 2026-07-14 13:23:38 +02:00
Masterluke77 eabbafbebd noop 2026-07-13 14:34:00 +02:00
vendoo-release-bot 4e73f71617 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-13 12:29:17 +00:00
Masterluke77 d14f237ffc test(ux): Command Platform in Releasevertrag aufnehmen 2026-07-13 14:29:04 +02:00
vendoo-release-bot 7f9e9e5f82 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-13 12:26:49 +00:00
Masterluke77 08836bffb4 test(ux): Command Platform Vertrag ergänzen 2026-07-13 14:26:38 +02:00
vendoo-release-bot a11fb7a338 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-13 12:26:00 +00:00
Masterluke77 5813194f43 feat(ux): Command Platform vor globaler Suche laden 2026-07-13 14:25:40 +02:00
vendoo-release-bot fc7f3329cd chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-13 12:21:10 +00:00
Masterluke77 2ade99f450 feat(ux): Command Platform Oberfläche ergänzen 2026-07-13 14:20:59 +02:00
vendoo-release-bot 337e45d18c chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-13 12:20:21 +00:00
Masterluke77 0af3330a01 feat(ux): Navigation Engine und Command Registry ergänzen 2026-07-13 14:20:06 +02:00
Masterluke77 79513ce5af chore: finalen Global-Navigation-Stand erneut prüfen 2026-07-13 09:52:18 +02:00
vendoo-release-bot 7ff7db62a1 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-13 07:50:59 +00:00
Masterluke77 ea9fb48421 test: Global-Navigation-Gate präzisieren 2026-07-13 09:50:45 +02:00
Masterluke77 58494b7eca chore: finalen Global-Navigation-Stand prüfen 2026-07-13 09:46:34 +02:00
vendoo-release-bot 0e0a6b1fba chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-13 07:44:44 +00:00
Masterluke77 187473b5da test: Global-Navigation-Gate in Gesamtvertrag aufnehmen 2026-07-13 09:44:29 +02:00
vendoo-release-bot 086b5e994a chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-13 07:42:40 +00:00
Masterluke77 d91a1a0143 test: globale Navigation und Suche absichern 2026-07-13 09:42:28 +02:00
vendoo-release-bot 13eaa0dd7d chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-13 07:41:38 +00:00
Masterluke77 ba1c5e0556 feat: globale Navigation aus zentraler UX-Runtime laden 2026-07-13 09:41:25 +02:00
vendoo-release-bot 1bdf503877 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-13 07:38:35 +00:00
Masterluke77 edb07b86e2 style: globale Suche und Navigationsverlauf gestalten 2026-07-13 09:38:20 +02:00
vendoo-release-bot e487c3a172 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-13 07:37:21 +00:00
Masterluke77 0eaf43575e feat: globale Suche und Navigationsverlauf ergänzen 2026-07-13 09:37:07 +02:00
Masterluke77 05002e0735 test: finalen Module-&-Extensions-Releasebaum prüfen 2026-07-13 09:03:19 +02:00
vendoo-release-bot 968112bac7 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 20:28:15 +00:00
Masterluke77 fee20f830a test(ux): correct module action contract marker 2026-07-12 22:28:00 +02:00
vendoo-release-bot 3fc9364db4 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 20:24:37 +00:00
Masterluke77 47c60a950d ci: preserve Windows release contract diagnostics 2026-07-12 22:24:21 +02:00
Masterluke77 ed3f706e83 chore: rerun Windows release contract for unchanged module workspace tree 2026-07-12 22:19:02 +02:00
Masterluke77 aca3db12c1 chore: validate corrected module extension workspace tree 2026-07-12 22:16:12 +02:00
vendoo-release-bot e951b1659b chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 20:14:58 +00:00
Masterluke77 2342828f24 fix(ux): avoid module workspace refresh name collision 2026-07-12 22:14:44 +02:00
Masterluke77 520bbc0268 chore: validate module extension workspace release tree 2026-07-12 22:11:15 +02:00
vendoo-release-bot 7aa25d4694 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 20:10:02 +00:00
Masterluke77 b76812267b test(ux): include module extension workspace gate 2026-07-12 22:09:49 +02:00
vendoo-release-bot 9f6883caa1 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 20:08:21 +00:00
Masterluke77 e76c6f98d5 test(ux): add module extension workspace gate 2026-07-12 22:08:09 +02:00
vendoo-release-bot a21034a1ac chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 20:07:28 +00:00
Masterluke77 53db515649 feat(ux): load module extension workspace 2026-07-12 22:07:16 +02:00
vendoo-release-bot 0ed5047833 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 20:04:41 +00:00
Masterluke77 3c9cd1fdaa feat(ux): style module and extension workspace 2026-07-12 22:04:30 +02:00
vendoo-release-bot 74e8c4d7b2 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 20:03:36 +00:00
Masterluke77 f6299b6b04 feat(ux): add module and extension workspace 2026-07-12 22:03:22 +02:00
Masterluke77 fac72410c1 chore: Settings-Control-Center final abnehmen 2026-07-12 21:51:32 +02:00
vendoo-release-bot 3aa88ec819 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 19:50:43 +00:00
Masterluke77 ba9cfc91af fix(test): Settings-Gate auf additive lokale Zustände begrenzen 2026-07-12 21:50:29 +02:00
vendoo-release-bot 2e9a7a11b3 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 19:49:35 +00:00
Masterluke77 2020861b8b fix(ux): Explizite Asset-Loader-Verträge wiederherstellen 2026-07-12 21:49:23 +02:00
Masterluke77 e8637954ba chore: Settings-Control-Center final abnehmen 2026-07-12 21:45:14 +02:00
vendoo-release-bot 376de97251 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 19:44:20 +00:00
Masterluke77 67f1cc15c0 fix(test): Reduced-Motion-Vertrag an zentrale Runtime anpassen 2026-07-12 21:44:06 +02:00
vendoo-release-bot ce6c09b5b5 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 19:43:04 +00:00
Masterluke77 8b76cd02bb test(ux): Settings-Control-Center in Gesamtvertrag aufnehmen 2026-07-12 21:42:53 +02:00
vendoo-release-bot 7a55767e21 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 19:41:40 +00:00
Masterluke77 0b597b75c8 feat(ux): Settings-Control-Center in Theme-Runtime laden 2026-07-12 21:41:25 +02:00
vendoo-release-bot 773bdf5606 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 19:40:14 +00:00
Masterluke77 3195047977 test(ux): Settings-Control-Center-Vertrag ergänzen 2026-07-12 21:40:03 +02:00
vendoo-release-bot 3208f107ba chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 19:39:22 +00:00
Masterluke77 1ce1407ea7 fix(ux): Settings-Control-Center gegen rekursive Navigation härten 2026-07-12 21:39:07 +02:00
vendoo-release-bot e9882719af chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 19:36:44 +00:00
Masterluke77 d2fa0d846c feat(ux): Control-Center-Layout für Einstellungen ergänzen 2026-07-12 21:36:31 +02:00
vendoo-release-bot dc8611ec3a chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 19:35:23 +00:00
Masterluke77 abcf23bf0a feat(ux): Einstellungen als Control Center strukturieren 2026-07-12 21:35:10 +02:00
Masterluke77 a915fe4b19 chore: finalize Today workspace verification 2026-07-12 11:58:36 +02:00
vendoo-release-bot 7c07cd4f89 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 09:56:44 +00:00
Masterluke77 36be085c5b fix(test): align today workspace contract markers 2026-07-12 11:56:30 +02:00
vendoo-release-bot 0a3bf7b251 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 09:56:07 +00:00
Masterluke77 f9d283004c test(ux): register today workspace gate 2026-07-12 11:55:55 +02:00
vendoo-release-bot a68f69005e chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 09:55:48 +00:00
Masterluke77 f8e7e26517 feat(ux): load today workspace assets 2026-07-12 11:55:31 +02:00
vendoo-release-bot 8e302fc925 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 09:55:16 +00:00
Masterluke77 ce59e83d16 test(ux): add today workspace contract 2026-07-12 11:54:57 +02:00
Masterluke77 c34d400a60 feat(ux): style guided today workspace 2026-07-12 11:54:37 +02:00
vendoo-release-bot 41bdc76413 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 09:54:25 +00:00
Masterluke77 d7b74e55a2 feat(ux): add guided today workspace 2026-07-12 11:54:11 +02:00
Masterluke77 b3673746ae chore: confirm template workspace release tree 2026-07-12 09:55:52 +02:00
vendoo-release-bot 32f531707b chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 07:54:49 +00:00
Masterluke77 de69c095f0 chore: remove accidental noop file 2026-07-12 09:54:30 +02:00
vendoo-release-bot bc5d05207c chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 07:54:29 +00:00
Masterluke77 d7819fbd7f noop 2026-07-12 09:54:17 +02:00
vendoo-release-bot b621851044 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 07:53:52 +00:00
Masterluke77 ae5c74291c test(ux): require persisted template filters and editor summary 2026-07-12 09:53:33 +02:00
vendoo-release-bot 7c06eb6a53 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 07:53:19 +00:00
Masterluke77 0d79050525 fix(test): align template workspace contract markers 2026-07-12 09:53:10 +02:00
vendoo-release-bot 4a35908617 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 07:53:03 +00:00
Masterluke77 bb42622cba fix(ux): harden template filter and editor state 2026-07-12 09:52:49 +02:00
vendoo-release-bot ad3cdeb744 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 07:52:22 +00:00
Masterluke77 5d2c7dc8df fix(ux): correct template workspace view states 2026-07-12 09:52:06 +02:00
vendoo-release-bot 50d124ca39 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 07:51:49 +00:00
Masterluke77 e4f39e1d31 test(ux): include template workspace gate 2026-07-12 09:51:33 +02:00
vendoo-release-bot b62029e2fc chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 07:51:30 +00:00
Masterluke77 11e9441d1a feat(ux): load template workspace assets 2026-07-12 09:51:14 +02:00
vendoo-release-bot b2d7335f83 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 07:50:50 +00:00
Masterluke77 ed9f58a57a test(ux): add template workspace gate 2026-07-12 09:50:33 +02:00
vendoo-release-bot f8944fd139 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 07:50:30 +00:00
Masterluke77 27854cf5f5 style(ux): add responsive template workspace 2026-07-12 09:50:19 +02:00
vendoo-release-bot d56ab6093a chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 07:50:01 +00:00
Masterluke77 3fe7fbdfca feat(ux): add guided template workspace 2026-07-12 09:49:48 +02:00
Masterluke77 27874d2224 chore: trigger final inventory workspace validation 2026-07-12 02:54:03 +02:00
vendoo-release-bot e5584d453a chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:53:13 +00:00
Masterluke77 141443a259 test(ux): align inventory gate with runtime markers 2026-07-12 02:53:02 +02:00
vendoo-release-bot e8f791bcdc chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:52:29 +00:00
Masterluke77 a9f729a411 test(ux): include inventory workspace gate 2026-07-12 02:52:23 +02:00
vendoo-release-bot 2e9cc62e9b chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:52:14 +00:00
Masterluke77 1258a832ff feat(ux): load inventory workspace assets 2026-07-12 02:52:07 +02:00
vendoo-release-bot a4891fac99 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:51:56 +00:00
Masterluke77 38fc197d4e test(ux): add inventory workspace release gate 2026-07-12 02:51:43 +02:00
vendoo-release-bot 958c800574 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:51:26 +00:00
Masterluke77 c9b532abfb fix(ux): harden inventory workspace observers 2026-07-12 02:51:12 +02:00
vendoo-release-bot cbc9974801 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:50:29 +00:00
Masterluke77 6f470879f2 feat(ux): style guided inventory workspace 2026-07-12 02:50:19 +02:00
vendoo-release-bot 80dd3ab65d chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:49:57 +00:00
Masterluke77 a07173eae6 feat(ux): add guided inventory workspace 2026-07-12 02:49:45 +02:00
Masterluke77 c6e0e280ed chore: remove accidental no-op file 2026-07-12 02:12:53 +02:00
Masterluke77 464b7e1215 temp 2026-07-12 02:12:40 +02:00
vendoo-release-bot 8b7762a62c chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:12:02 +00:00
Masterluke77 16334f7c5b fix(ux): constrain quality center state observer 2026-07-12 02:11:41 +02:00
vendoo-release-bot 48a0431777 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:11:26 +00:00
Masterluke77 4c88cbd0c8 test(ux): include quality center gate in release contract 2026-07-12 02:11:13 +02:00
vendoo-release-bot 303210ebf6 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:11:06 +00:00
Masterluke77 04a09fe4fa feat(ux): load quality center workspace assets 2026-07-12 02:10:59 +02:00
vendoo-release-bot b98aedd5c7 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:10:47 +00:00
Masterluke77 386aee5e56 test(ux): add quality center release gate 2026-07-12 02:10:36 +02:00
vendoo-release-bot 68c992c8ed chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:10:11 +00:00
Masterluke77 17269ea2c9 feat(ux): style guided quality center workspace 2026-07-12 02:09:54 +02:00
vendoo-release-bot 78bf10fad6 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:09:37 +00:00
Masterluke77 b2f8af16d8 feat(ux): add guided quality center workspace 2026-07-12 02:09:23 +02:00
Masterluke77 d9164c34e7 chore: validate FLUX Studio UX release head 2026-07-12 02:04:07 +02:00
vendoo-release-bot 61a4f1e93a chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:03:34 +00:00
Masterluke77 6e8467bcd0 fix(ux): scope FLUX Studio state observation 2026-07-12 02:03:23 +02:00
vendoo-release-bot a78b57a22e chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:02:29 +00:00
Masterluke77 3942184ae2 fix(ux): align FLUX Studio gate with runtime loader 2026-07-12 02:02:20 +02:00
vendoo-release-bot 74343b1fb9 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:02:10 +00:00
Masterluke77 9bb9d3250d test(ux): include FLUX Studio UX gate in release contract 2026-07-12 02:01:57 +02:00
vendoo-release-bot c868ba80b6 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:01:53 +00:00
Masterluke77 8aad710f3e feat(ux): load FLUX Studio UX assets 2026-07-12 02:01:42 +02:00
vendoo-release-bot 84ad3d88a9 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:01:17 +00:00
Masterluke77 3c8cc9cf26 test(ux): add FLUX Studio UX release gate 2026-07-12 02:01:10 +02:00
Masterluke77 764e4dc615 feat(ux): style guided FLUX Studio workspace 2026-07-12 02:00:58 +02:00
vendoo-release-bot b4c2216a26 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-12 00:00:43 +00:00
Masterluke77 6423674897 feat(ux): add guided FLUX Studio workspace 2026-07-12 02:00:28 +02:00
Masterluke77 01ff755f53 revert: zweite versehentliche Testdatei entfernen 2026-07-11 09:34:25 +02:00
Masterluke77 ae160917e3 no 2026-07-11 09:34:17 +02:00
Masterluke77 7b7ecfb73d revert: versehentlich angelegte Root-Datei entfernen 2026-07-11 09:31:53 +02:00
Masterluke77 83db4676d5 x 2026-07-11 09:31:41 +02:00
Masterluke77 fa703b817d chore: finalen Bildwerkstatt-CI-Trigger entfernen 2026-07-11 09:30:10 +02:00
Masterluke77 2163bbbd28 chore: finale Bildwerkstatt-Abnahme anstoßen 2026-07-11 09:30:00 +02:00
vendoo-release-bot 62d905b8de chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:28:20 +00:00
Masterluke77 a2da68c5bf test: Auftragsnavigation im Bildwerkstatt-Vertrag absichern 2026-07-11 09:28:06 +02:00
vendoo-release-bot 22d81327e1 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:27:32 +00:00
Masterluke77 08ada03db6 fix: Bildwerkstatt-Schrittnavigation auf Auftragsbereich ausrichten 2026-07-11 09:27:18 +02:00
vendoo-release-bot 9aa9200f70 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:26:20 +00:00
Masterluke77 91f6f326cb fix: Bildwerkstatt-Vertrag auf dataset-Marker abstimmen 2026-07-11 09:26:08 +02:00
vendoo-release-bot 77a6fc432d chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:25:50 +00:00
Masterluke77 f5108890ef feat: Bildwerkstatt UX-Schicht zentral laden 2026-07-11 09:25:29 +02:00
vendoo-release-bot b5d7d76d89 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:25:15 +00:00
Masterluke77 b6246cd4a5 test: Bildwerkstatt-Gate in Releasevertrag aufnehmen 2026-07-11 09:25:03 +02:00
vendoo-release-bot 52d3d214ee chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:24:58 +00:00
Masterluke77 40ae350caf test: Bildwerkstatt UX-Vertrag absichern 2026-07-11 09:24:44 +02:00
Masterluke77 8a1e02d8f8 style: Bildwerkstatt responsiv und schrittweise gestalten 2026-07-11 09:24:31 +02:00
vendoo-release-bot e7d7667d0c chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:24:09 +00:00
Masterluke77 3be28a8e17 feat: Bildwerkstatt als geführten Produktionsablauf strukturieren 2026-07-11 09:23:52 +02:00
Masterluke77 4816875a65 chore(ci): remove media UX gate fix trigger 2026-07-11 09:15:40 +02:00
Masterluke77 126384f39e chore(ci): trigger media UX gate fix validation 2026-07-11 09:15:27 +02:00
vendoo-release-bot 917fb9f645 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:15:07 +00:00
Masterluke77 c62b5ecf37 fix(test): match complete HTTP method tokens in media UX gate 2026-07-11 09:14:52 +02:00
Masterluke77 bbbe5d4166 chore(ci): remove final media library validation trigger 2026-07-11 09:12:52 +02:00
Masterluke77 db9696b37c chore(ci): trigger final media library validation 2026-07-11 09:12:44 +02:00
vendoo-release-bot 82cf6a100f chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:12:00 +00:00
Masterluke77 490581b72d test(ux): include media library gate in release contract 2026-07-11 09:11:49 +02:00
vendoo-release-bot 26916b79e6 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:11:42 +00:00
Masterluke77 e2c630543c feat(ux): load media library experience 2026-07-11 09:11:33 +02:00
vendoo-release-bot 7af6f7e72b chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:11:23 +00:00
Masterluke77 99c50c5321 test(ux): add media library acceptance gate 2026-07-11 09:11:05 +02:00
vendoo-release-bot 99c410a07a chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:11:03 +00:00
Masterluke77 9aff28f4ad feat(ux): add media library workflow and preview navigation 2026-07-11 09:10:50 +02:00
vendoo-release-bot e869ddfe1e chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 07:10:26 +00:00
Masterluke77 d560723c1c feat(ux): add focused media library presentation 2026-07-11 09:10:13 +02:00
Masterluke77 43a033bc5b ci: remove final publish center validation trigger 2026-07-11 08:56:34 +02:00
Masterluke77 e55a832cf4 ci: trigger final publish center validation 2026-07-11 08:56:21 +02:00
vendoo-release-bot ce0fd7ba06 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:55:42 +00:00
Masterluke77 a472b6d475 test(ux): include publish center gate in release contract 2026-07-11 08:55:26 +02:00
vendoo-release-bot 6a1fd08ef3 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:55:21 +00:00
Masterluke77 533146c9b6 feat(ux): load publish center workspace layer 2026-07-11 08:55:12 +02:00
vendoo-release-bot 3f22f09521 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:55:04 +00:00
Masterluke77 05f1a7bc3f fix(ux): stabilize publish center observers 2026-07-11 08:54:48 +02:00
vendoo-release-bot eda6720c35 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:54:23 +00:00
Masterluke77 fc9850a3a7 test(ux): add publish center contract gate 2026-07-11 08:54:10 +02:00
vendoo-release-bot 873d2a2283 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:54:06 +00:00
Masterluke77 375e0e3ebc feat(ux): add publish center layout and hierarchy 2026-07-11 08:53:51 +02:00
vendoo-release-bot d2c03abb10 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:53:32 +00:00
Masterluke77 3e516c5799 feat(ux): simplify publish center workflow 2026-07-11 08:53:20 +02:00
Masterluke77 00cee8d2d6 ci: finalize article overview head 2026-07-11 08:41:52 +02:00
Masterluke77 b48e03e17b ci: register final article overview head 2026-07-11 08:41:43 +02:00
vendoo-release-bot d51ea85a82 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:41:00 +00:00
Masterluke77 df383644e2 test: add article overview UX gate 2026-07-11 08:40:41 +02:00
Masterluke77 83d61e2daf feat: load article overview UX assets 2026-07-11 08:40:28 +02:00
vendoo-release-bot 4ac62db921 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:40:25 +00:00
Masterluke77 61d914ecbb test: add article overview UX contract 2026-07-11 08:40:06 +02:00
Masterluke77 2eb759cd49 style: add article overview cards and filters 2026-07-11 08:39:55 +02:00
vendoo-release-bot 0fde47c9d2 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:39:42 +00:00
Masterluke77 56982d9bee feat: add guided article overview UX 2026-07-11 08:39:28 +02:00
Masterluke77 2c5dcf880e fix(release): restore reproducible manifest after UX workflow 2026-07-11 08:34:08 +02:00
Masterluke77 17fe7ea369 ci: refresh release manifest after generator workflow 2026-07-11 08:31:30 +02:00
vendoo-release-bot b6411a3453 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:27:49 +00:00
Masterluke77 23ab1573dd ci: finalize generator workflow head 2026-07-11 08:27:35 +02:00
vendoo-release-bot 964d9d4b14 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:27:31 +00:00
Masterluke77 368f4af822 ci: register final generator workflow head 2026-07-11 08:27:18 +02:00
vendoo-release-bot ce34392214 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:26:47 +00:00
Masterluke77 bc980703f6 test(ux): include generator workflow in release contract 2026-07-11 08:26:39 +02:00
vendoo-release-bot 84f932ce8b chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:26:29 +00:00
Masterluke77 2e7d7ce717 feat(ux): load guided generator workflow module 2026-07-11 08:26:16 +02:00
vendoo-release-bot 28f3d85a7a chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:26:11 +00:00
Masterluke77 c19b00a9b9 test(ux): add generator workflow contract 2026-07-11 08:25:54 +02:00
Masterluke77 3252a0afa4 feat(ux): style guided article generator workflow 2026-07-11 08:25:41 +02:00
vendoo-release-bot d285850844 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 06:25:28 +00:00
Masterluke77 b3756fb4d6 feat(ux): add guided article generator workflow 2026-07-11 08:25:16 +02:00
Masterluke77 6a063e9a18 ci: finalize UX acceptance head 2026-07-11 02:58:44 +02:00
Masterluke77 74bc0046ac ci: register final UX acceptance head 2026-07-11 02:58:35 +02:00
vendoo-release-bot 5fedeeced3 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 00:58:05 +00:00
Masterluke77 633fd06ac1 chore: remove one-time UX integration workflow 2026-07-11 02:57:55 +02:00
Masterluke77 1580417f8a test: validate UX foundation runtime integration 2026-07-11 02:57:48 +02:00
Masterluke77 2cdc409e8d test: include UX foundation in full release contract 2026-07-11 02:57:38 +02:00
vendoo-release-bot 398393b1af chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 00:57:34 +00:00
Masterluke77 a98f43d407 feat: load UX foundation through stable theme runtime 2026-07-11 02:57:20 +02:00
vendoo-release-bot 8472e65496 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 00:56:27 +00:00
Masterluke77 d533a66079 ci: add one-time UX foundation integration workflow 2026-07-11 02:56:14 +02:00
vendoo-release-bot 4a07fd87ff chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 00:56:13 +00:00
Masterluke77 34ce7722a0 test: add UX foundation verification gate 2026-07-11 02:56:01 +02:00
Masterluke77 0cbc8b8995 feat: add Vendoo UX foundation behavior 2026-07-11 02:55:52 +02:00
vendoo-release-bot 685b9786a5 chore: Release-Manifest synchronisieren [manifest-sync] 2026-07-11 00:55:45 +00:00
Masterluke77 f58d2a3e30 feat: add Vendoo UX foundation styles 2026-07-11 02:55:32 +02:00
Masterluke77 0a3d0b86e5 revert: remove unintended UX placeholder 2026-07-11 02:55:04 +02:00
Masterluke77 20f2e9a299 placeholder 2026-07-11 02:54:56 +02:00
Masterluke77andGitHub 637275effc Updater 3.4 – Lifecycle Hardening (#28)
Production-ready updater lifecycle hardening with final-head checks, Windows PowerShell 5.1 compatibility, reproducible package generation, provenance validation, payload rollback protection and documented acceptance.
2026-07-10 19:54:36 +02:00
Masterluke77andGitHub 40071f718e Vendoo 1.43.0 – Production Update
Manifestbasierter Production-Updater 3.3, Modulnavigation, optionales Produktbild Studio, Listing-Studio-Aktionsleiste sowie plattformübergreifende Windows-/Linux-Release-Gates.
2026-07-10 13:16:21 +02:00
196 changed files with 25988 additions and 583 deletions
+6
View File
@@ -0,0 +1,6 @@
root = true
[*.{bat,cmd}]
charset = latin1
end_of_line = crlf
insert_final_newline = true
+5
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
+145
View File
@@ -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
+27 -24
View File
@@ -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
+36
View File
@@ -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`.
+90
View File
@@ -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
View File
@@ -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.
+3 -13
View File
@@ -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%
+34 -14
View File
@@ -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
+51
View File
@@ -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%
+75 -15
View File
@@ -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-]*)+$"
}
}
}
}
+32
View File
@@ -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();
+5 -1
View File
@@ -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 });
}
+1
View File
@@ -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')),
+2 -1
View File
@@ -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 });
}
+2 -1
View File
@@ -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(),
];
}
+1 -1
View File
@@ -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(); },
},
};
+6 -2
View File
@@ -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"
]
}
+192
View File
@@ -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();
+76 -6
View File
@@ -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() }),
});
}
+525 -74
View File
@@ -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; },
});
}
+41
View File
@@ -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));
}
+13
View File
@@ -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"
]
}
+21
View File
@@ -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 } }),
+15 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+123
View File
@@ -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.
+35
View File
@@ -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 0x000x7F
- 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.
+108
View File
@@ -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.
+30
View File
@@ -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.
+22
View File
@@ -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.
+39
View File
@@ -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.
+25
View File
@@ -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.
+19
View File
@@ -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`.
+30
View File
@@ -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`
+20
View File
@@ -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.
+22
View File
@@ -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
+18
View File
@@ -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.
+11
View File
@@ -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.
+40
View File
@@ -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.
+48
View File
@@ -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.
+17
View File
@@ -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.
+189
View File
@@ -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.
+44
View File
@@ -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
+14
View File
@@ -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.
+16
View File
@@ -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
View File
@@ -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 {}
+20 -7
View File
@@ -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); }
}
+2 -2
View File
@@ -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
View File
@@ -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
+617
View File
@@ -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
View File
@@ -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); }
}
+497
View File
@@ -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;
}
}
+317
View File
@@ -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();
}
})();
+1
View File
@@ -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}}
+453
View File
@@ -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();
})();
+169 -1
View File
@@ -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]),
});
});
+537
View File
@@ -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;
}
}
+478
View File
@@ -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();
}
})();
+459
View File
@@ -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;
}
}
+387
View File
@@ -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();
})();
+373
View File
@@ -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;
}
}
+456
View File
@@ -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();
})();
+523
View File
@@ -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;
}
}
+366
View File
@@ -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
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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>
+533
View File
@@ -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;
}
}
+380
View File
@@ -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 AZ'], ['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
View File
@@ -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>
+695
View File
@@ -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;
}
+406
View File
@@ -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();
}
})();
+26
View File
@@ -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}
+268
View File
@@ -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