Compare commits

...

253 Commits

Author SHA1 Message Date
E44 203e48c31c bump to v0.0.18 2026-01-19 20:46:38 +01:00
E44 8b878b183b refactor(control): remove unnecessary small function 2026-01-19 20:28:07 +01:00
E44 a38827da54 fix(control): deselect file for sync 2026-01-19 20:24:45 +01:00
2mal3 25c0fe2b4b fix(display): libreoffice lock issues
libreoffice cli is bad documented and broken shit
2026-01-19 19:19:58 +01:00
E44 c96b8fe7e4 bump to version v0.0.17 2026-01-19 18:58:28 +01:00
E44 671c74f25d chore(control): save text and url 2026-01-19 18:57:56 +01:00
2mal3 4d5d9849da feat: support sending modifier keys (#18)
Co-authored-by: E44 <129310925+programmer-44@users.noreply.github.com>
2026-01-19 18:47:53 +01:00
E44 c8e21a64bf fix(control): fix warning in compile process 2026-01-19 18:36:33 +01:00
E44 2711d89f22 chore(control): show folder_size as sum of all folder_elements 2026-01-19 18:33:59 +01:00
E44 c09e4b2d8d chore(control): set size of all folders in db to 0 2026-01-19 17:57:32 +01:00
E44 209080db22 refactor(control): replace === 'inode/directory' with is_folder() 2026-01-19 17:54:47 +01:00
2mal3 4acbc96a58 chore(control): prevent closing popups by clicking outside (#20)
Multiple test users closed various popups accidentally by clicking outside and rarely used the feature to close the popup.
2026-01-19 17:40:53 +01:00
E44 99643f8029 chore(control): display transferring file correctly (still a few bugs) 2026-01-19 15:39:53 +01:00
2mal3 ef7dfa49fe chore(control/ping): increase timeout 2026-01-19 12:43:22 +01:00
E44 7cfa56a906 chore(control): set preview update mode in dev mode to never automatically 2026-01-18 22:34:43 +01:00
E44 63c7cea4ca bump to v0.0.16 2026-01-18 22:24:35 +01:00
E44 0e87ca3ae1 fix(control): screenshot_loop is no longer blocking, preview states are now consistently changed 2026-01-18 22:22:30 +01:00
2mal3 d61ef0fe94 fix: browser insecurity warning 2026-01-18 22:13:03 +01:00
E44 5d1f3e2d20 refactor(control): correct spelling 2026-01-18 19:11:24 +01:00
E44 6dfb29bbd1 chore(control): improve handling with offline displays 2026-01-18 19:05:15 +01:00
2mal3 49410bf372 chore(control): more user friendly open website pop up 2026-01-18 18:59:12 +01:00
2mal3 5d22f62a6e chore(control): german button names 2026-01-18 18:52:22 +01:00
2mal3 a31080c602 chore(control): rename keyboard input 2026-01-18 18:47:59 +01:00
2mal3 ea3cbcb8c6 chore(control): title case for buttons and headings 2026-01-18 18:46:21 +01:00
2mal3 a0cf45c8dc chore: consistent display naming 2026-01-18 18:41:23 +01:00
E44 97d2608adf fix(control): wrong url in not-dev-mode 2026-01-18 18:23:49 +01:00
E44 36d2887247 fix(control): unknown status at app_start 2026-01-18 18:23:01 +01:00
E44 1e9148bff2 fix(control): use local ip address only in dev mode 2026-01-18 18:17:04 +01:00
2mal3 346e511bb4 bump to version v0.0.15 2026-01-18 18:01:11 +01:00
2mal3 54e10b98e2 feat: show website button (#15) 2026-01-18 18:00:19 +01:00
E44 83eaf34475 chore(control): add settings with screenshot preview settings 2026-01-18 17:34:57 +01:00
2mal3 b251e8951f deps(control): update package-lock to follow package.json 2026-01-18 15:42:02 +01:00
2mal3 31cb614958 feat(control): about section 2026-01-18 15:38:29 +01:00
E44 8fb9f5b9db refactor(api_handler): better return types, formatting 2026-01-18 13:28:10 +01:00
E44 d13fd347ac chore(control): change visual error structure in api_handler 2026-01-18 13:20:52 +01:00
E44 922b47bd1e chore(control): improve app startup and display status ping behavior 2026-01-18 13:17:53 +01:00
E44 b57ea762bf fix(control): limit loading bar to 100% size 2026-01-18 12:29:06 +01:00
E44 c171706d96 fix(control): improve visual feedback of sync (still not finished) 2026-01-18 12:25:43 +01:00
E44 6d5aa9041f chore(control): add backend of sync, visuals still buggy 2026-01-18 11:59:14 +01:00
E44 c2d9a2b049 refactor(control): create gloabel type CompleteFileLoadingData 2026-01-18 11:58:28 +01:00
E44 f84a7c0520 refactor(control): rename get_current_folder_elements to get_folder_elements 2026-01-18 11:57:26 +01:00
E44 7f6891ecff chore(control): change create_folder api to create_path 2026-01-18 11:55:51 +01:00
2mal3 d63fc5cac4 fix: couldn't open control & display at the same time 2026-01-18 00:34:02 +01:00
E44 12f4ec7d6c refactor(control): improve structure in api_handler 2026-01-17 16:34:49 +01:00
2mal3 86f23077ea refactor(nixos): rename update.sh to update.nu 2026-01-17 15:17:46 +01:00
2mal3 47d462ba76 fix(nixos): wol broken 2026-01-17 15:17:12 +01:00
E44 5533696aad fix(control): rename function remove_file_from_display_recusively 2026-01-17 15:12:59 +01:00
2mal3 ac68939abd fix(control): glitchy add display popup 2026-01-17 13:18:02 +01:00
2mal3 d6bafd4e6f style(control): tailwind.config.js 2026-01-17 13:18:02 +01:00
E44 ce28a1f771 fix(control): delete files now recursively 2026-01-17 13:10:44 +01:00
E44 7632229704 fix(control): add path creation before uploading 2026-01-17 13:10:12 +01:00
2mal3 b5e997e675 chore(control): remove qr-code button for now 2026-01-17 12:56:59 +01:00
2mal3 ca08d6fb6a chore(control): remove "Weitere Einstellungen" for now 2026-01-17 12:51:12 +01:00
2mal3 4ab8f43295 fix(control): doesn't remember added displays 2026-01-17 12:50:12 +01:00
2mal3 fc59b576b5 bump to version v0.0.13 2026-01-15 15:18:48 +01:00
2mal3 e2a9558d3f fix(control): sometimes could not start browser 2026-01-15 15:16:02 +01:00
2mal3 662bcb3b8d deps(frontend): update 2026-01-15 15:04:27 +01:00
2mal3 9044262ab6 deps: update insecure go packages 2026-01-15 14:56:36 +01:00
E44 6c654e295c refactor(control): remove unnecessary comments and logs 2026-01-14 22:48:57 +01:00
E44 a2bbf19fee fix(control): Drag and Drop in DisplayView is now better 2026-01-14 22:48:02 +01:00
E44 246549acdf fix(control): folders could only be created in path '/' 2026-01-14 19:25:21 +01:00
E44 80cee15576 fix(control): handle_enter wasn't connected to valid_state 2026-01-14 19:06:38 +01:00
E44 1c59b40971 fix(control): startup and shutdown buttons were not reactive and flickered 2026-01-14 19:00:19 +01:00
E44 548f8ccba8 refactor(control): better indentation in ui_behavior 2026-01-14 18:48:57 +01:00
E44 5b93250e22 fix(control): Drag and Drop in DisplayView was completely buggy, now its just a little bit buggy 2026-01-14 18:48:10 +01:00
2mal3 5ca2487598 fix: wrong version number 2026-01-13 23:21:21 +01:00
2mal3 464422ec51 chore(nixos): remove rustdesk again because problems 2026-01-13 23:01:00 +01:00
2mal3 128bbb4ca2 bump to version v0.0.11 2026-01-13 20:03:49 +01:00
E44 8686a1cb47 fix(control): wrong scrollbar behavior in FileView while upload_finish_animation 2026-01-12 20:04:50 +01:00
E44 d3f8a0134b fix(control): wrong scrollbar behavior in notifications 2026-01-12 20:03:59 +01:00
E44 ea2f063991 fix(control): uploaded files never finished animation 2026-01-12 19:56:51 +01:00
E44 9be8a70452 fix: fade out splash-screen in display block directly after receiving command 2026-01-11 17:55:03 +01:00
E44 9ce2c1d8bb fix: bring version-label in splash screen behind bouncing cursor 2026-01-11 17:43:28 +01:00
2mal3 ce8133d59e docs: reference nixpkgs for requirements 2026-01-11 00:12:22 +01:00
E44 b0a57f9c67 chore: singe source of truth for version & show it on splash screen (#7)
Co-authored-by: 2mal3 <56305732+2mal3@users.noreply.github.com>
2026-01-10 20:06:10 +01:00
E44 c64eb345fd fix(display): change title to PLG MuDiCS Display 2026-01-10 19:04:07 +01:00
2mal3 9ed35750c4 fix(nixos): could not open pdf files 2026-01-10 18:43:54 +01:00
2mal3 2b58c8bc69 fix(control): could not shutdown 2026-01-10 18:23:40 +01:00
2mal3 9fdd2c4729 feat(nixos): rustdesk 2026-01-10 18:08:04 +01:00
2mal3 db925e1509 deps(nixos): update to 25.11 2026-01-10 18:07:37 +01:00
2mal3 438c64f055 fix(display): cursor visible 2026-01-10 01:06:29 +01:00
2mal3 6a65ac7e70 chore(control): change settings icon to menu
better describes the purpose
2026-01-10 01:02:17 +01:00
2mal3 74779740f7 chore(control): tell the user why mac address is needed 2026-01-10 00:57:50 +01:00
2mal3 5d868bc1fd refactor(control): move splash screen to layout 2026-01-10 00:52:52 +01:00
2mal3 8b7aabb473 fix(control): slpash screen only shows in dev mode 2026-01-10 00:52:52 +01:00
2mal3 fe5cc22cc0 chore(control/ControlView): disable currently unusable buttons 2026-01-10 00:52:52 +01:00
2mal3 30138ebe04 chore(display): dont hard fail when view reset fails 2026-01-10 00:52:52 +01:00
2mal3 f9c3161cf6 fix(control): hide dev display when not in dev mode 2026-01-10 00:52:52 +01:00
2mal3 0692034344 feat(control): proper website title 2026-01-10 00:52:52 +01:00
2mal3 c175e9d3f8 chore(display/takeScreenshot): remove annoying error log 2026-01-10 00:36:44 +01:00
2mal3 e27cf170ef fix(control): cors failed 2026-01-10 00:35:25 +01:00
2mal3 0d6d3a6012 chore(control): more detailed server error logs 2026-01-10 00:34:24 +01:00
2mal3 426ea4802e fix(nixos): missing command line tools 2026-01-10 00:23:43 +01:00
2mal3 bdcb0a8ce3 fix(control): could not start display over wol 2026-01-10 00:22:53 +01:00
E44 09adf5dba4 chore: improve splash screen with randomized animation of cursor-movement 2026-01-09 21:30:46 +01:00
2mal3 89ce7402df chore(display/keyboardInput): follow new docs [untested] 2026-01-09 11:21:02 +01:00
E44 45e8e87b35 chore(control): improve logging in file_transfer_handler 2026-01-08 19:41:20 +01:00
E44 e308d6a42a chore(control): add sync functionality [untested] 2026-01-08 19:36:37 +01:00
E44 bfcc109c80 fix(control): fix missing refactoring of loading_data 2026-01-08 18:56:20 +01:00
E44 f9f6d84381 refactor(control): remove unnecessary comments 2026-01-08 18:40:13 +01:00
E44 1e42f390f5 refactor(control): remove downloads from tasks 2026-01-08 18:31:44 +01:00
E44 754f466d73 chore(control): change to new keyboard_input api 2026-01-08 18:05:58 +01:00
2mal3 1bd76c666d docs(display): more efficent keyboard input api 2026-01-08 10:52:25 +01:00
2mal3 46e59d35bf chore(control/run_on_all_selected_displays): parallel processing 2026-01-06 19:58:24 +01:00
2mal3 0614746d95 chore(control): return request result in power apis 2026-01-06 19:43:13 +01:00
2mal3 f76337efc7 feat(control): startup button 2026-01-06 19:41:15 +01:00
2mal3 f7b0c9978d chore(control/un_on_all_selected_displays): allow ignoring of offline check 2026-01-06 19:39:44 +01:00
2mal3 3389a3bf50 docs(control): wake on lan bruno requst 2026-01-06 19:26:33 +01:00
2mal3 58570552eb feat(control): put displays in loading state when shutting down 2026-01-06 19:20:29 +01:00
2mal3 427209e518 refactor(control): make run_on_all_selected_displays more generic 2026-01-06 19:18:18 +01:00
2mal3 4325ffc4aa refactor(control): rename file_display.db to database 2026-01-06 19:04:23 +01:00
2mal3 c4d7f8e0e4 fix(control): toggle select all 2026-01-05 23:14:25 +01:00
2mal3 ef64330050 chore(control): default bg color for settings button 2026-01-05 23:05:02 +01:00
2mal3 c7b073eb34 refactor(control): use error button class more 2026-01-05 23:00:13 +01:00
2mal3 58e56ffae3 feat(control): shutdown 2026-01-05 23:00:13 +01:00
E44 0c619bdc1d refactor(control): remove unnecessary statement 2026-01-05 22:46:17 +01:00
E44 cc958581bf chore(control): add download function 2026-01-05 22:45:00 +01:00
2mal3 aa0f88b258 chore(control): run only on all online selected displays 2026-01-05 21:42:26 +01:00
2mal3 28121df128 feat(control): hide unusable power options 2026-01-05 21:42:26 +01:00
E44 6f0cb7298a refactor(control): improve file transfer backend data structures 2026-01-05 21:40:11 +01:00
E44 6f0b05ca67 chore(control): added upload finished animation 2026-01-05 19:09:53 +01:00
E44 a8a59bd543 fix(control): replaced overflow-auto with overflow-y-auto in notifications 2026-01-05 19:09:34 +01:00
E44 b6be6bd9c4 fix(control): only one upload was possible 2026-01-05 18:37:41 +01:00
E44 12e500e988 refactor(control): improve db update of upload percentage 2026-01-05 18:29:05 +01:00
E44 b775fae9c0 refactor(control): remove unnecessary logging 2026-01-04 23:27:54 +01:00
E44 2367dc0c9e chore(control): add progress_bar in InodeElement 2026-01-04 23:25:19 +01:00
E44 fa42feb808 chore(control): increase z-level of PopUp 2026-01-04 23:24:28 +01:00
E44 fa16bb699c fix(control): uploaded_percentage was normally set to 0 2026-01-04 23:23:49 +01:00
E44 44290f46fe chore(control + display): improve api urls, paths and file_names 2026-01-04 23:22:59 +01:00
E44 a2c1f385c1 refactor(control): undo last commit 2026-01-04 23:01:10 +01:00
2mal3 8b41711df8 refactor: clearer log messages 2026-01-04 21:31:23 +01:00
2mal3 bdfc648f51 chore(control): only try getting description if json response 2026-01-04 21:29:38 +01:00
2mal3 a49d10521e fix(control): blackout broken 2026-01-04 21:13:35 +01:00
2mal3 c8920df9b3 chore: better logging
log some things only in dev mode
2026-01-04 21:02:14 +01:00
E44 9843565c7b chore(control): add basic upload logic 2026-01-04 18:27:54 +01:00
2mal3 5f9bd20c06 docs: add GNU AGPL v3 license 2026-01-04 02:09:29 +01:00
E44 18eddde385 fix(display): fix bruno send_keyboard_input json body 2026-01-03 16:46:01 +01:00
E44 628e7cb5c6 fix(control): fixed date_created display strings 2026-01-03 16:09:16 +01:00
E44 56ddddf919 fix(control): removed date_created completly from Inode and fixed the displayed created date in InodeElement 2026-01-03 15:55:17 +01:00
E44 3f7fc30ce2 fix(control): remove empty menu object on every button 2026-01-03 15:15:05 +01:00
E44 0a35fcc543 chore(control): improve zoom-in/zoom-out buttons 2026-01-03 15:13:35 +01:00
E44 5531ff0ffa refactor(control): move components from /routes to /components and /svgs 2026-01-03 15:04:59 +01:00
E44 9072029945 fix(control): close popups immediately after submitting -> ui feels faster and second input is not longer possible 2026-01-03 15:01:55 +01:00
E44 a7b38d88de fix(control): run run_for_selected_files_on_selected_displays only for selected files, not for all files 2026-01-03 14:54:15 +01:00
E44 19337de98f chore(control): improve scrollbar look 2026-01-03 14:42:19 +01:00
E44 5464b829f0 fix(control): add maximum notification height and add optional scrollbar 2026-01-03 12:51:38 +01:00
2mal3 028e419535 fix(frontend): more reactive db problems
Co-Authored-By: E44 <129310925+programmer-44@users.noreply.github.com>
2026-01-02 16:50:52 +01:00
E44 2391169f46 fix(control): fix reactive db problem in current_folder_elements 2026-01-02 16:34:42 +01:00
2mal3 87c5a68127 refactor(frontend) 2025-12-29 19:02:58 +01:00
2mal3 e4bf1ccfc1 refactor(frontend): split true components from modules 2025-12-29 18:57:43 +01:00
2mal3 6da0d69125 refactor(frontend): remove test page 2025-12-29 18:39:11 +01:00
2mal3 abf227d15d refactor(frontent): move components and ts to lib folder 2025-12-29 18:38:20 +01:00
2mal3 54b9e1fc78 refactor(frontend): small syntax improvements 2025-12-29 18:26:54 +01:00
2mal3 3fc67dadd9 test(frontend): more tests 2025-12-29 18:21:30 +01:00
2mal3 7dbc746242 test(frontend): basic e2e testing framework 2025-12-29 17:30:25 +01:00
2mal3 ac75494f6d chore(frontend): require min dependency age for npm deps 2025-12-29 17:27:55 +01:00
2mal3 c6b0c1cf31 refactor(frontend): use db for files & displays
Co-Authored-By: E44 <129310925+programmer-44@users.noreply.github.com>
2025-12-29 16:28:26 +01:00
2mal3 880725a105 chore: nix dev shell support 2025-12-19 13:53:58 +01:00
2mal3 6a6c13018e chore(control): clearer view zoom button icons 2025-12-14 13:41:52 +01:00
2mal3 9ea4e31863 fix(nixos): screen blank after inactivity 2025-12-06 14:10:21 +01:00
2mal3 cfb26a0b47 fix(display): custom program file handling 2025-12-03 17:29:45 +01:00
2mal3 df1ea7b163 chore(display): open presentations read only 2025-12-03 16:25:21 +01:00
2mal3 013a3ca487 refactor: use mime types for file identification 2025-12-03 16:18:14 +01:00
2mal3 4cf0f12a6f feat(display): file streaming (#5) 2025-12-03 14:51:41 +00:00
2mal3 9cafb293d0 refactor(display): move open logic into own file 2025-11-29 00:00:02 +01:00
2mal3 78fde49329 feat(display): pdf support 2025-11-28 23:57:02 +01:00
2mal3 a7184847bb fix(nixos): not all pkgs available at service runtime 2025-11-28 23:00:49 +01:00
2mal3 fe847339e4 chore(display): use seperate libreoffice profie 2025-11-28 22:51:43 +01:00
2mal3 84c01d791a fix(display): deprecated libreoffice api 2025-11-28 22:38:46 +01:00
2mal3 54902357b0 fix(nixos): could not send keyboard 2025-11-28 16:34:09 +01:00
2mal3 88b80fab58 fix(nixos): flake not renamed to just mudics 2025-11-28 16:29:50 +01:00
2mal3 23002bd983 chore(nixos): rename plg-mudics to mudics 2025-11-28 16:03:18 +01:00
2mal3 f62a595e4a fix(nixos): update-mudics ping not installed 2025-11-28 15:59:12 +01:00
E44 9d1e739be7 refactor(control): improve click_functions in ControlView 2025-11-26 11:27:29 +01:00
E44 302630b519 refactor(control): change folder_element id from optional to required 2025-11-26 11:26:46 +01:00
2mal3 ac28c99764 chore(control/TipTapButton): better describe color 2025-11-24 21:25:51 +01:00
2mal3 b501ba1a7f chore: recommend vscode extensions 2025-11-24 21:06:28 +01:00
E44 f0ed1fcfc9 chore(control): add rename file 2025-11-23 20:45:52 +01:00
E44 841aeae589 fix(control): deselect automatically on update_current_folder_on_selected_displays 2025-11-23 20:45:19 +01:00
E44 3f118739be fix(control): run_for_selected_files_on_selected_displays now works with same named folders on different displays 2025-11-23 20:44:43 +01:00
2mal3 debc0d1b53 deps(display): tidy up 2025-11-23 13:47:56 +01:00
2mal3 6fe3a5f1c9 style(control/app.css) 2025-11-23 13:33:56 +01:00
2mal3 497f7c6fe1 deps(control): update deno lock 2025-11-23 13:22:15 +01:00
2mal3 fbf98b656d feat(control): wake on lan api 2025-11-23 13:22:15 +01:00
2mal3 fa851eb51d refactor: share bad request text 2025-11-23 13:22:15 +01:00
E44 c95ce4bde4 fix(control): unnecessary file update requests in empty folders removed 2025-11-23 13:12:12 +01:00
2mal3 cad8f0dc76 chore(keyboardInput): new api from docs (#4) 2025-11-23 12:02:34 +00:00
E44 2767d57a8c chore(control): improve logging and debugging in api_handler 2025-11-23 12:49:46 +01:00
E44 5b7f872c60 fix(control): remove second delete request 2025-11-23 12:48:48 +01:00
E44 804826136d fix(control): string bug delete 2025-11-23 00:59:54 +01:00
E44 27a253e751 fix(control): improve shell command error handling 2025-11-23 00:59:30 +01:00
E44 70b381d1f9 fix(control): fix create folder on main level path 2025-11-23 00:58:59 +01:00
E44 4445c5ea50 chore(control): add shell command error handling 2025-11-23 00:35:34 +01:00
E44 330c09e9a7 fix(control): add filter_file_selection_for_current_selected_displays after all display selects 2025-11-23 00:27:04 +01:00
E44 7488492190 chore(control): add enter action to TextInput 2025-11-23 00:26:40 +01:00
E44 9c5212dbac chore(control): disable file buttons if no display is selected 2025-11-22 23:58:26 +01:00
E44 c1afc20658 fix(control): deselect files from displays which were deselected 2025-11-22 23:57:46 +01:00
E44 79ca11300a refactor(control): improve wording 2025-11-22 23:36:45 +01:00
E44 7423212fe9 fix(control): deselect display after deletion 2025-11-22 23:30:59 +01:00
E44 efb99cbac0 chore(control): add delete file and create folder action 2025-11-22 23:30:36 +01:00
E44 4cf2633bc1 fix(control): better folder view, especially for same named folders on different displays 2025-11-22 23:22:31 +01:00
E44 826696ac9c fix(control) add selected_display subsciption validation in TextInput 2025-11-22 23:17:07 +01:00
E44 76b8b9ab9c refactor(control): add HighlightedText 2025-11-22 23:16:28 +01:00
E44 352c1a2a10 refactor(control): remove unnecessary file name check and commented code 2025-11-22 14:00:52 +01:00
E44 4c3522a63c refactor(control): remove unnecessary html syntax in blackout 2025-11-22 13:16:13 +01:00
E44 74f2b5f5a7 fix(control): remove tailwindcss/typography from app.css 2025-11-22 13:12:16 +01:00
E44 408ffc3e22 refactor(control): move display_status_to_info to utils 2025-11-21 22:09:30 +01:00
E44 5b891ae64c chore: add display text 2025-11-21 22:08:27 +01:00
2mal3 94bad9a72f chore(nixos): check for internet before updating 2025-11-20 16:59:35 +01:00
2mal3 3519c76526 refactor(display): remove unused code 2025-11-20 11:35:48 +01:00
2mal3 b8d0ada4af refactor: sort deps 2025-11-20 11:33:08 +01:00
E44 47fdff2676 chore(display): improve display startup behavior with qr-code 2025-11-19 19:59:12 +01:00
E44 7ae607cc50 chore(control): add focus_on_start to TextInput 2025-11-19 17:12:26 +01:00
E44 23b54c7b13 chore(control): follow api docs, improve api code structure 2025-11-19 17:05:23 +01:00
2mal3 3891fd7a4a chore(display): follow api docs 2025-11-18 14:55:21 +01:00
2mal3 3b8647e031 feat(control): store api (#3) 2025-11-18 14:43:21 +01:00
2mal3 2ab9c82ccb docs(display): api (#2)
* docs(display): api

* docs(display/keyboardInput): more generic interface

* docs(display): clarify error section
2025-11-18 12:43:51 +01:00
2mal3 2499389c30 refactor(display): easy custom background color 2025-11-15 11:25:39 +01:00
2mal3 c653538b31 chore(display): directly bundle htmx
for more independence and offline support
2025-11-15 11:16:22 +01:00
2mal3 273879c55a fix(display/filePreview): non quadratic images 2025-11-15 11:00:43 +01:00
E44 0b6ab994ce finalize thumbnails and add shared supported_file_types json 2025-11-13 19:55:05 +01:00
E44 851cf708e8 show file name extension if file type isn't supported 2025-11-11 15:07:52 +01:00
E44 a247f59dd5 add file thumbnails 2025-11-10 12:45:46 +01:00
E44 706e8350af start to implement show text 2025-11-09 19:51:47 +01:00
E44 3b47f463d1 fix button state warning 2025-11-09 18:58:14 +01:00
2mal3 a9a28eb1fb docs: centralized dev 2025-11-09 00:01:53 +01:00
2mal3 e030b0469c docs: add description to readme 2025-11-08 23:54:27 +01:00
2mal3 8d9b540c81 docs: rough architecture documentation 2025-11-08 23:51:44 +01:00
E44 69cea6c980 handle cd directory errors 2025-11-08 22:41:09 +01:00
E44 7888a0aa6e improve logging 2025-11-08 22:18:10 +01:00
E44 e7588ce2d2 improve ux design with disabled buttons and info texts while no display is selected 2025-11-08 22:15:33 +01:00
E44 ea74609349 show blackscreen added 2025-11-08 22:03:12 +01:00
E44 bd5a15e3b5 improve splitpane behavior and look 2025-11-08 21:51:41 +01:00
E44 e2dbbcc70d add missing TreeElement Object 2025-11-08 21:47:54 +01:00
E44 6c6a2bdbee add svelte splitpanes for pinned display view 2025-11-08 21:47:44 +01:00
E44 be3f309d0a add description to pinned display status 2025-11-08 20:52:05 +01:00
E44 aaafa44c84 improve control main display structure 2025-11-08 20:51:40 +01:00
E44 3fc82637e4 add missing svg sans-serif 2025-11-08 20:07:54 +01:00
E44 c579c9ae2c add more efficient file update structure (tree command) 2025-11-08 20:07:35 +01:00
2mal3 c117cb2199 feat(nixos): custom firewall 2025-11-07 20:08:49 +01:00
2mal3 53598578f3 refactor(display): correct data folder name 2025-11-07 19:59:50 +01:00
2mal3 b94c80bfc6 fix(display): closing app wont stop server 2025-11-07 19:51:18 +01:00
2mal3 aa7d1e7ee6 feat: nixos config (#1) 2025-11-07 13:26:10 +01:00
2mal3 93f7b17222 chore(openBrowser): more detailed error logs 2025-11-07 12:42:34 +01:00
E44 0bfe6371fd add Keyboard Input, improve PopUp, improve general code 2025-11-07 12:15:58 +01:00
E44 10a09bc45e add recursive file handling 2025-11-06 16:07:37 +01:00
2mal3 bf08ec946a deps: udpdate 2025-11-06 15:46:41 +01:00
E44 e0f7f36383 add update_all_display_status interval, improve code structure 2025-11-06 12:52:56 +01:00
E44 cf8eeacb5e improve api error handling 2025-11-06 12:51:55 +01:00
E44 2168873d3a add control requests, use pings and improve display_status 2025-11-06 11:50:21 +01:00
E44 9fd81821f1 add Notifications 2025-11-06 11:49:32 +01:00
2mal3 9487fdd13e docs: add lines of code badge 2025-11-05 22:16:15 +01:00
2mal3 aeea501a05 ci(release): fix could not find assets 2025-11-05 22:05:45 +01:00
104 changed files with 11229 additions and 4090 deletions
+1
View File
@@ -5,6 +5,7 @@ on:
paths-ignore:
- "**/*.md"
- "**/*.yml"
- "**/*.sh"
workflow_dispatch:
jobs:
+7 -1
View File
@@ -10,19 +10,25 @@ permissions:
jobs:
release:
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Zip NixOS Config
run: zip -rj nixos.zip nixos/
- name: Release
uses: softprops/action-gh-release@v2
with:
name: PLG-MuDiCS ${{ github.ref_name }}
body_path: CHANGELOG.md
files: nixos.zip
build:
name: Build and Upload Assets
needs: release
runs-on: ubuntu-latest
strategy:
@@ -63,5 +69,5 @@ jobs:
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: plg-mudics-${{ matrix.block }}
file: ${{ matrix.block }}/plg-mudics-${{ matrix.block }}
tag: ${{ github.ref_name }}
+10
View File
@@ -0,0 +1,10 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"golang.go",
"esbenp.prettier-vscode",
"svelte.svelte-vscode",
"a-h.templ",
"bradlc.vscode-tailwindcss"
]
}
+34
View File
@@ -0,0 +1,34 @@
# Contributing
## Development
### Architecture
The software suite comprises two main components that are designed to work together on the same network.
The _Display Block_ is responsible for displaying content on a monitor.
The _Control Block_ controls multiple Display Blocks via a user interface. One Display Block can be controlled by multiple Control Blocks.
- `control/`: Go code for the Control Block.
- `frontend/`: The actual frontend written in SvelteKit.
- `display/`: Go code for the Display Block.
- `nixos/`: NixOS configuration for a self-updating, ready-to-use Linux system for the Display Block. This is not necessary for using PLG-MuDiCS.
- `shared/`: Shared code and assets for the Control and Display Block.
### Technologies
- **Go** for the base code in both the Control and Display Block.
- **SvelteKit** in prerender mode for the frontend of the Control Block.
- **TailwindCSS** for styling the frontend of the Control Block.
- **Chromium** browser for handling most media display in the Display Block and serving the frontend of the Control Block.
- **LibreOffice** for handling more complex documents in the Display Block.
### Requirements
- Go v1.25 or higher
- Deno v2
### Running
1. open the wanted folder of the block inside a terminal
2. run `go generate ./..` to generate files required at build time
3. run `go run *.go` to compile and run the block
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+12
View File
@@ -1 +1,13 @@
# PLG-MuDiCS
![Lines of Code](https://tokei.rs/b1/github/PLG-Development/PLG-MuDiCS?category=code)
Open source solution for advanced remote control and coordination of one or more presentation displays. Specially optimized for fast operation in highly dynamic multi-display situations.
## Display Requirements
See [packages in the NixOS config](https://github.com/PLG-Development/PLG-MuDiCS/blob/b0a57f9c679e8c483d1a81cb3c749cf8555785f6/nixos/configuration.nix#L86).
## Contributing
See [Contributing.md](/CONTRIBUTING.md)
-3
View File
@@ -1,3 +0,0 @@
# PLG-MuDiCS Control
## Development
BIN
View File
Binary file not shown.
+3
View File
@@ -21,3 +21,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# test
test-results/
+3
View File
@@ -0,0 +1,3 @@
{
"minimumDependencyAge": "P7D"
}
+1125 -502
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
import { expect, test } from '@playwright/test';
test('page loads', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('PLG MuDiCS')).toBeVisible();
});
test('page loads without problems', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('PLG MuDiCS')).toBeVisible();
await expect(page.getByTestId('notification')).not.toBeVisible();
});
test('diplay click shows files', async ({ page }) => {
await page.goto('/');
await page.getByTestId('display').click();
await expect(page.getByTestId('inode').first()).toBeVisible();
});
test('show text', async ({ page }) => {
await page.goto('/');
await page.getByTestId('display').click();
const controlButton = page.getByText('Text anzeigen');
await expect(controlButton).toBeVisible();
await controlButton.click();
const textPopup = page.getByTestId('text-popup');
await expect(textPopup).toBeVisible();
const textArea = textPopup.getByRole('textbox');
await expect(textArea).toBeVisible();
await textArea.fill('Hello, world!');
const submitButton = textPopup.locator('button').filter({ hasText: 'Text anzeigen' });
await submitButton.click();
await expect(
page.locator('[data-testid="notification"]:not(:has-text("Fehler 500"))')
).not.toBeVisible();
});
+2175 -838
View File
File diff suppressed because it is too large Load Diff
+35 -23
View File
@@ -11,33 +11,45 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint . && prettier --check .",
"format": "prettier --write ."
"format": "prettier --write .",
"test:e2e": "playwright test",
"test": "deno task test:e2e"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-dnd-action": "^0.9.65",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4"
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.2",
"@playwright/test": "1.56.1",
"@sveltejs/adapter-auto": "^6.1.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.3",
"@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/extension-placeholder": "^3.15.3",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"sass-embedded": "^1.97.2",
"svelte": "^5.46.1",
"svelte-check": "^4.3.5",
"svelte-dnd-action": "^0.9.69",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.52.0",
"vite": "^7.3.1"
},
"dependencies": {
"@thisux/sveltednd": "^0.0.20",
"lucide-svelte": "^0.545.0"
"@tiptap/core": "^3.15.3",
"@tiptap/extension-highlight": "^3.15.3",
"@tiptap/extension-text-style": "^3.15.3",
"@tiptap/extensions": "^3.15.3",
"@tiptap/pm": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"dexie": "^4.2.1",
"lucide-svelte": "^0.545.0",
"svelte-splitpanes": "^8.0.12"
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
fullyParallel: true,
webServer: {
command: 'deno task build && deno task preview',
port: 4173
},
testDir: 'e2e'
});
+84 -2
View File
@@ -1,6 +1,4 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
/* Adding more color steps */
@theme {
@@ -13,3 +11,87 @@
--color-stone-750: oklch(0.321 0.0085 50.928);
--color-stone-850: oklch(0.242 0.0065 45.171);
}
.link {
@apply text-blue-300 underline;
&:hover {
@apply text-blue-400;
}
}
.button {
&.space {
@apply px-4;
}
&.error {
@apply text-red-400 hover:text-stone-100;
&:hover {
@apply bg-red-400;
}
}
}
html {
scrollbar-width: thin;
scrollbar-color: var(--color-stone-300) transparent;
}
/* Splitpanes */
.splitpanes.mudics-stone-theme {
.splitpanes__pane {
@apply bg-stone-900;
}
.splitpanes__splitter {
@apply bg-transparent hover:bg-stone-700 active:bg-stone-600 transition-colors duration-200 shrink-0 relative box-border rounded mx-5;
/* box-sizing: border-box;
position: relative;
flex-shrink: 0; */
&:before,
&:after {
@apply bg-stone-750 transition-colors duration-200 absolute top-[50%] left-[50%] content-[''] rounded;
}
&:hover:before,
&:hover:after {
@apply bg-stone-400;
}
&:active:before,
&:active:after {
@apply bg-stone-300;
}
&:first-child {
/* cursor: auto; */
}
}
}
.mudics-stone-theme {
&.splitpanes .splitpanes .splitpanes__splitter {
@apply z-10;
}
&.splitpanes--horizontal > .splitpanes__splitter,
.splitpanes--horizontal > .splitpanes__splitter {
@apply h-2 cursor-row-resize;
&:before,
&:after {
@apply h-0.5 w-10 translate-x-[-50%];
}
&:before {
@apply mt-[calc(-0.7*var(--spacing))];
}
&:after {
@apply mt-[calc(0.1*var(--spacing))];
}
}
}
@@ -1,61 +0,0 @@
<script lang="ts">
import {
ArrowBigLeft,
ArrowBigRight,
ChevronDown,
Keyboard,
Power,
PowerOff,
Presentation,
SquareTerminal,
TextAlignStart,
TrafficCone
} from 'lucide-svelte';
import Button from './Button.svelte';
</script>
<div class="grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl min-w-0">
<div class="text-xl font-bold pl-3 content-center bg-stone-700 rounded-t-2xl truncate min-w-0">
Bildschirme steuern
</div>
<div class="flex flex-col gap-2 p-2 overflow-auto">
<div class="flex flex-row justify-between gap-2">
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 w-70 justify-normal">
<Button title="Vorherige Folie (Pfeil nach Links)" className="px-7"
><ArrowBigLeft /></Button
>
<Button title="Nächste Folie (Pfeil nach Rechts)" className="px-7"
><ArrowBigRight /></Button
>
</div>
<Button className="px-3 flex gap-3 w-70 justify-normal"
><TextAlignStart /> Text anzeigen</Button
>
<Button className="px-3 flex gap-3 w-70 justify-normal"><Presentation />Blackout</Button>
<div class="flex flex-row justify-normal">
<Button className="rounded-r-none pl-3 flex gap-3 grow w-60 justify-normal"
><TrafficCone /> Fallback-Bild anzeigen</Button
>
<Button className="rounded-l-none flex grow-0 w-10"><ChevronDown /></Button>
</div>
<Button className="px-3 flex gap-3 w-70 justify-normal"
><Keyboard /> Tastatur-Inputs durchgeben</Button
>
</div>
<div class="flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2">
<Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal"
><Power /> PC hochfahren</Button
>
<Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal"
><PowerOff /> PC herunterfahren</Button
>
</div>
<Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal"
><SquareTerminal /> Shell-Befehl ausführen</Button
>
</div>
</div>
</div>
</div>
@@ -1,111 +0,0 @@
<script lang="ts">
import { dragHandleZone, TRIGGERS } from 'svelte-dnd-action';
import { dnd_flip_duration_ms, get_selectable_color_classes, is_display_drag, is_group_drag } from '../ts/stores/ui_behavior';
import { cubicOut } from 'svelte/easing';
import { flip } from 'svelte/animate';
import DisplayObject from './DisplayObject.svelte';
import {
add_empty_display_group,
all_displays_of_group_selected,
remove_empty_display_groups,
select_all_of_group,
set_new_display_group_data
} from '../ts/stores/displays';
import DNDGrip from './DNDGrip.svelte';
import { fade } from 'svelte/transition';
import type { DisplayGroup, MenuOption } from '../ts/types';
import { selected_display_ids } from '../ts/stores/select';
let { display_group, get_display_menu_options } = $props<{
display_group: DisplayGroup;
get_display_menu_options: (display_id: string) => MenuOption[]
}>();
let hovering_selectable = $state(false);
function select_all_of_this_group() {
const new_value = !all_displays_of_group_selected(display_group, $selected_display_ids);
select_all_of_group(display_group, new_value);
}
function handle_consider(e: CustomEvent) {
const { items, info } = e.detail;
if (items.length !== 1 && info.trigger === TRIGGERS.DRAG_STARTED) {
$is_display_drag = true;
add_empty_display_group();
}
set_new_display_group_data(display_group.id, items);
}
function handle_finalize(e: CustomEvent) {
remove_empty_display_groups();
$is_display_drag = false;
set_new_display_group_data(display_group.id, e.detail.items);
}
</script>
<div
transition:fade={{ duration: 100 }}
class="{get_selectable_color_classes(
all_displays_of_group_selected(display_group, $selected_display_ids),
{
bg: true,
hover: hovering_selectable,
active: hovering_selectable,
text: true
},
-150,
-50
)} transition-colors duration-200 rounded-2xl flex flex-row cursor-pointer"
>
<div
class="flex flex-col min-w-0 pl-2 py-2 gap-2 w-full"
use:dragHandleZone={{
items: display_group.data,
type: 'item',
flipDurationMs: dnd_flip_duration_ms,
dropTargetStyle: { outline: 'none' },
}}
onconsider={handle_consider}
onfinalize={handle_finalize}
>
{#each display_group.data as display (display.id)}
<!-- Each Group -->
<section
animate:flip={{ duration: $is_group_drag ? 0 : dnd_flip_duration_ms, easing: cubicOut }}
class="outline-none"
role="figure"
>
<DisplayObject {display} {get_display_menu_options} />
</section>
{/each}
{#if display_group.data.length === 0}
<div class="min-h-10 h-full w-full pl-2 py-2 flex justify-center items-center">
Hier in leere neue Gruppe ablegen
</div>
{/if}
</div>
<div
class="flex items-center justify-center px-2"
onclick={(e) => select_all_of_this_group()}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') select_all_of_this_group();
}}
onmouseenter={() => (hovering_selectable = true)}
onmouseleave={() => (hovering_selectable = false)}
>
<DNDGrip
bg={get_selectable_color_classes(
all_displays_of_group_selected(display_group, $selected_display_ids),
{
bg: true
},
-150
)}
/>
</div>
</div>
@@ -1,131 +0,0 @@
<script lang="ts">
import Button from './Button.svelte';
import {
current_height,
get_selectable_color_classes,
pinned_display_id
} from '../ts/stores/ui_behavior';
import DNDGrip from './DNDGrip.svelte';
import { Menu, Pencil, Pin, PinOff, Trash2, VideoOff, X } from 'lucide-svelte';
import OnlineState from './OnlineState.svelte';
import type { Display, MenuOption } from '../ts/types';
import { is_selected, select, selected_display_ids } from '../ts/stores/select';
let { display, get_display_menu_options } = $props<{
display: Display;
get_display_menu_options: (display_id: string) => MenuOption[];
}>();
let hovering_unselectable = $state(false);
function onclick(e: Event) {
select(selected_display_ids, display.id);
e.stopPropagation();
}
function on_preview_click(e: MouseEvent) {
if ($pinned_display_id === display.id) {
$pinned_display_id = null;
} else {
$pinned_display_id = display.id;
}
e.stopPropagation();
}
</script>
<div
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick(e);
}}
{onclick}
class="p-1 {get_selectable_color_classes(is_selected(display.id, $selected_display_ids), {
bg: true,
hover: true,
active: !hovering_unselectable,
text: true
})} rounded-xl flex flex-row justify-between h-{$current_height.display} transition-colors duration-100 gap-2 cursor-pointer w-full text-stone-200"
>
<div class="flex flex-row gap-4 min-w-0 flex-1">
<!-- Left Preview Screen -->
<button
class="group relative aspect-16/9 bg-stone-800 h-full rounded-lg overflow-hidden cursor-pointer text-stone-200 transition-colors duration-200"
onmouseenter={() => (hovering_unselectable = true)}
onmouseleave={() => (hovering_unselectable = false)}
onclick={on_preview_click}
>
<div class="flex h-full w-full items-center justify-center">
{#if $pinned_display_id === display.id}
<div class="size-[50%]">
<Pin class="size-full" />
</div>
{:else if display.preview_url}
<img src={display.preview_url} alt="preview" class="w-full object-cover bg-black" />
{:else}
<!-- No Signal -->
<div class="size-full bg-black flex justify-center items-center">
<VideoOff class="size-[30%]" />
</div>
{/if}
</div>
<!-- Hover Effect -->
<span
class="pointer-events-none absolute inset-0 {$pinned_display_id === display.id
? 'bg-stone-700'
: 'bg-stone-700/70'} opacity-0 transition-opacity duration-200 flex items-center justify-center group-hover:opacity-100"
>
{#if $pinned_display_id === display.id}
<PinOff class="size-[50%]" aria-hidden="true" />
{:else}
<Pin class="size-[50%]" aria-hidden="true" />
{/if}
</span>
</button>
<!-- Middle Text Block -->
<div
class="h-full flex flex-col justify-center gap-1 select-none
min-w-0 basis-0 flex-1"
>
<div class="text-xl font-bold truncate w-full" title={display.name}>
{display.name}
</div>
<OnlineState
selected={is_selected(display.id, $selected_display_ids)}
status={display.status}
/>
</div>
</div>
<!-- Right Controls -->
<div class="flex flex-row h-full items-center gap-2 pr-3">
<DNDGrip
bg={get_selectable_color_classes(is_selected(display.id, $selected_display_ids), {
bg: true
})}
/>
<div
role="figure"
onmouseenter={() => (hovering_unselectable = true)}
onmouseleave={() => (hovering_unselectable = false)}
>
<Button
bg="bg-transparent"
hover_bg={get_selectable_color_classes(is_selected(display.id, $selected_display_ids), {
bg: true
})}
active_bg={get_selectable_color_classes(is_selected(display.id, $selected_display_ids), {
bg: true
})}
click_function={(e) => {
e.stopPropagation();
}}
menu_options={get_display_menu_options(display.id)}
>
<Menu />
</Button>
</div>
</div>
</div>
@@ -1,240 +0,0 @@
<script lang="ts">
import { fade, scale, slide } from 'svelte/transition';
import {
all_displays_of_group_selected,
displays,
get_display_by_id,
select_all_of_group
} from '../ts/stores/displays';
import {
change_height,
current_height,
dnd_flip_duration_ms,
get_selectable_color_classes,
is_display_drag,
is_group_drag,
next_height_step_size,
pinned_display_id
} from '../ts/stores/ui_behavior';
import { type Display, type DisplayGroup, type MenuOption } from '../ts/types';
import Button from './Button.svelte';
import OnlineState from './OnlineState.svelte';
import { cubicOut } from 'svelte/easing';
import { Menu, Minus, Pencil, PinOff, Plus, Settings, Trash2, VideoOff } from 'lucide-svelte';
import { selected_display_ids } from '../ts/stores/select';
import { dragHandleZone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import DisplayGroupObject from './DisplayGroupObject.svelte';
let { handle_display_deletion, handle_display_editing } = $props<{
handle_display_deletion: (display_id: string) => void;
handle_display_editing: (display_id: string) => void;
}>();
let displays_scroll_box: HTMLElement;
let pinned_display: Display | null = $derived(
get_display_by_id($pinned_display_id || '', $displays)
);
function get_display_menu_options(display_id: string): MenuOption[] {
return [
{
icon: Pencil,
name: 'Bildschirm bearbeiten',
on_select: () => {handle_display_editing(display_id)},
},
{
icon: Trash2,
name: 'Bildschirm löschen',
class: 'text-red-400 hover:text-stone-200 hover:!bg-red-400 active:!bg-red-500',
on_select: () => {handle_display_deletion(display_id)},
}
];
}
function select_all(current_displays: DisplayGroup[], current_selected_display_ids: string[]) {
const new_value = !all_selected(current_displays, current_selected_display_ids);
for (const display_group of current_displays) {
select_all_of_group(display_group, new_value);
}
}
function all_selected(current_displays: DisplayGroup[], current_selected_display_ids: string[]) {
for (const display_group of current_displays) {
if (!all_displays_of_group_selected(display_group, current_selected_display_ids)) {
return false;
}
}
return true;
}
function on_wheel(e: WheelEvent) {
if (!$is_group_drag && !$is_display_drag) return;
if (!displays_scroll_box) return;
// apply custom scroll feature
e.preventDefault();
(displays_scroll_box as HTMLElement).scrollBy?.({
top: e.deltaY,
behavior: 'auto'
});
}
</script>
<svelte:window on:wheel={on_wheel} />
<div class="h-[calc(100dvh-3rem-(6*var(--spacing)))] flex flex-col gap-2">
{#if $pinned_display_id}
<!-- Pinned Item -->
<div in:fade={{ duration: 140 }} out:fade={{ duration: 120 }}>
<div
class="grid grid-rows-[2.5rem_auto] will-change-[height,opacity] overflow-hidden rounded-2xl"
transition:slide={{ duration: 260, easing: cubicOut }}
>
<div class="bg-stone-700 flex justify-between w-full p-1 min-w-0 basis-0 flex-1">
<span
class="text-xl font-bold pl-2 content-center truncate min-w-0"
title={pinned_display?.name}
>
{pinned_display?.name}
</span>
<div class="flex flex-row gap-1">
<OnlineState
selected={false}
status={pinned_display?.status ?? ''}
className="flex items-center px-2"
/>
<Button
className="aspect-square !p-1"
bg="bg-stone-600"
click_function={(e) => {
e.stopPropagation();
}}
menu_options={get_display_menu_options($pinned_display_id)}
>
<Menu />
</Button>
<Button
title="Bildschirm nicht mehr anpinnen"
className="aspect-square !p-1"
bg="bg-stone-600"
click_function={() => {
$pinned_display_id = null;
}}
>
<PinOff />
</Button>
</div>
</div>
<div class="w-full h-[30dvh] bg-stone-800 flex justify-center items-center">
{#if pinned_display?.preview_url}
<img
src={pinned_display.preview_url}
alt="preview"
class="max-h-full max-w-full object-cover bg-black"
/>
{:else}
<div class="size-full bg-black flex justify-center items-center">
<VideoOff class="size-[20%]" />
</div>
{/if}
</div>
</div>
</div>
{/if}
<div class="min-h-0 h-full grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl overflow-hidden">
<!-- Normal Heading Left -->
<div class="bg-stone-700 flex justify-between w-full p-1 gap-2 min-w-0">
<span class="text-xl font-bold pl-2 content-center truncate min-w-0">
Verbundene Bildschirme
</span>
<div class="flex flex-row gap-1">
<button
class="gap-2 min-w-40 px-4 rounded-xl cursor-pointer duration-200 transition-colors {get_selectable_color_classes(
all_selected($displays, $selected_display_ids),
{
bg: true,
hover: true,
active: true,
text: true
}
)}"
onclick={() => select_all($displays, $selected_display_ids)}
>
<span
>{all_selected($displays, $selected_display_ids)
? 'Alle abwählen'
: 'Alle auswählen'}</span
>
</button>
<div class="flex flex-row">
<Button
title="Bildschirme größer darstellen"
className="aspect-square !p-1 rounded-r-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, 1))}
click_function={() => {
change_height('display', 1);
}}
>
<Plus />
</Button>
<Button
title="Bildschirme kleiner darstellen"
className="aspect-square !p-1 rounded-l-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, -1))}
click_function={() => {
change_height('display', -1);
}}
>
<Minus />
</Button>
</div>
</div>
</div>
<div class="min-h-0 overflow-y-auto" bind:this={displays_scroll_box}>
<div
class="min-h-full p-2 flex flex-col gap-4"
use:dragHandleZone={{
items: $displays,
type: 'group',
flipDurationMs: dnd_flip_duration_ms,
dropFromOthersDisabled: true,
dropTargetStyle: { outline: 'none' }
}}
onconsider={(e: CustomEvent) => {
$is_group_drag = true;
$displays = e.detail.items;
}}
onfinalize={(e: CustomEvent) => {
$displays = e.detail.items;
$is_group_drag = false;
}}
>
{#if $displays.length === 1 && $displays[0].data.length === 0}
<div class="text-stone-500 px-10 py-6 leading-relaxed text-center">
Es wurden noch keine Bildschirme hinzugefügt. Klicke oben rechts auf <Settings
class="inline pb-1"
/> und "Neuen Bildschirm hinzufügen".
</div>
{:else}
{#each $displays as display_group (display_group.id)}
<!-- Each Group -->
<section
out:scale={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
animate:flip={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
class="outline-none"
>
<DisplayGroupObject {display_group} {get_display_menu_options} />
</section>
{/each}
{/if}
</div>
</div>
</div>
</div>
@@ -1,106 +0,0 @@
<script lang="ts">
import { ClipboardPaste, Download, FolderPlus, Info, Minus, Pen, Plus, RefreshCcw, Scissors, Trash2, Upload } from "lucide-svelte";
import { change_height, current_height, next_height_step_size } from "../ts/stores/ui_behavior";
import Button from "./Button.svelte";
import PathBar from "./PathBar.svelte";
import { selected_display_ids, selected_file_ids } from "../ts/stores/select";
import { all_files, current_file_path, get_current_folder_elements } from "../ts/stores/files";
import { slide } from "svelte/transition";
import FolderElementObject from "./FolderElementObject.svelte";
import PopUp from "./PopUp.svelte";
</script>
<div class="bg-stone-800 h-full rounded-2xl grid grid-rows-[2.5rem_1fr] min-h-0">
<div class="bg-stone-700 flex justify-between w-full p-1 rounded-t-2xl min-w-0 gap-2">
<span class="text-xl font-bold pl-2 content-center truncate min-w-0">
Dateien anzeigen und verwalten
</span>
<div class="flex flex-ro">
<Button
title="Dateien größer darstellen"
className="aspect-square !p-1 rounded-r-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('file', $current_height, 1))}
click_function={() => {
change_height('file', 1);
}}
>
<Plus />
</Button>
<Button
title="Dateien kleiner darstellen"
className="aspect-square !p-1 rounded-l-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('file', $current_height, -1))}
click_function={() => {
change_height('file', -1);
}}
>
<Minus />
</Button>
</div>
</div>
<div class="flex flex-col gap-2 p-2 overflow-hidden relative rounded-b-2xl">
<div class="flex flex-col gap-2 p-2 bg-stone-750 rounded-xl">
<PathBar />
<div class="flex flex-row justify-between gap-6 overflow-x-auto">
<div class="flex flex-row gap-2 shrink-0">
<Button
title="Neuen Ordner erstellen (Neuen Ordner mit ausgewählten Objekten erstellen)"
className="px-3 flex"><FolderPlus /></Button
>
<div class="border border-stone-700 my-1"></div>
<Button title="Datei(en) hochladen" className="px-3 flex"><Upload /></Button>
<Button
title="Ausgewählte Datei(en) herunterladen"
className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Download /></Button
>
<div class="border border-stone-700 my-1"></div>
<Button
title="Aktuellen Ordner / Ausgewählte Datei(en) zwischen Bildschirmen synchronisieren"
className="px-3 flex gap-3"
><RefreshCcw />
<span class="hidden 2xl:flex">Synchronisieren</span>
</Button>
</div>
<div class="flex flex-row gap-2">
<Button
title="Ausgewählte Datei(en) ausschneiden"
className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Scissors /></Button
>
<Button title="Ausgewählte Datei(en) einfügen" className="px-3 flex"
><ClipboardPaste /></Button
>
<div class="border border-stone-700 my-1"></div>
<Button
title="Ausgewählte Datei umbenennen"
className="px-3 flex"
disabled={$selected_file_ids.length !== 1}><Pen /></Button
>
<Button
title="Ausgewählte Datei(en) löschen"
hover_bg="bg-red-400"
active_bg="bg-red-500"
className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Trash2 /></Button
>
</div>
</div>
</div>
<div class="min-h-0 h-full overflow-y-auto bg-stone-750 rounded-xl">
<div class="flex flex-col gap-2 p-2 min-h-0">
{#each get_current_folder_elements($all_files, $current_file_path, $selected_display_ids) as folder_element (folder_element.id)}
<section in:slide={{ duration: 100 }} class="outline-none">
<FolderElementObject file={folder_element} />
</section>
{/each}
</div>
</div>
<!-- <PopUp title="Test" title_icon={Info}>
<div>Hier kann beliebiges HTML übergeben werden!</div>
</PopUp> -->
</div>
</div>
@@ -1,250 +0,0 @@
<script lang="ts">
import {
ArrowRight,
Ban,
FileIcon,
Folder,
Play,
RefreshCcwDot,
TriangleAlert
} from 'lucide-svelte';
import {
current_height,
get_selectable_color_classes,
get_shifted_color
} from '../ts/stores/ui_behavior';
import Button from './Button.svelte';
import { supported_file_types, type FolderElement, type SupportedFileType } from '../ts/types';
import {
is_selected,
select,
selected_display_ids,
selected_file_ids
} from '../ts/stores/select';
import {
all_files,
change_file_path,
current_file_path,
get_display_ids_where_file_is_missing
} from '../ts/stores/files';
import RefreshPlay from './RefreshPlay.svelte';
import { get_file_size_display_string } from '../ts/utils';
import { open_file } from '../ts/api_handler';
import { displays, get_display_by_id, update_screenshot } from '../ts/stores/displays';
let { file } = $props<{ file: FolderElement }>();
const is_folder = file.type === 'inode/directory';
function get_file_type(file: FolderElement): SupportedFileType | null {
for (const key of Object.keys(supported_file_types)) {
if (file.type === supported_file_types[key].mime_type) {
return supported_file_types[key];
}
}
// Fallback:
const extension = file.name.split('.').pop();
if (extension) {
if (Object.keys(supported_file_types).includes('.' + extension)) {
return supported_file_types['.' + extension];
}
}
return null;
}
function get_created_string(date_object: Date, full_string = false) {
if (full_string) {
return (
get_formated_date_string(date_object, true) + ' ' + get_formated_time_string(date_object)
);
} else if (date_object.toDateString() === new Date().toDateString()) {
return get_formated_time_string(date_object);
} else {
return get_formated_date_string(date_object);
}
}
function get_formated_time_string(date_object: Date) {
return `${date_object.getHours().toString().padStart(2, '0')}:${date_object.getMinutes().toString().padStart(2, '0')}`;
}
function get_formated_date_string(date_object: Date, full_year = false) {
return `${date_object.getDate().toString().padStart(2, '0')}.${(date_object.getMonth() + 1).toString().padStart(2, '0')}.${date_object
.getFullYear()
.toString()
.slice(full_year ? 0 : 2)}`;
}
function get_grayed_out_text_color_strings(is_selected: boolean): string {
const color = is_selected ? 'text-stone-600' : 'text-stone-400';
const factor = is_selected ? -1 : 1;
return `${color} group-hover:${get_shifted_color(color, factor * 100)} group-active:${get_shifted_color(color, factor * 150)}`;
}
function get_grayed_out_border_color_strings(is_selected: boolean): string {
const color = is_selected ? 'border-stone-450' : 'border-stone-550';
const factor = is_selected ? 1 : 1;
return `${color} group-hover:${get_shifted_color(color, factor * 100)} group-active:${get_shifted_color(color, factor * 150)}`;
}
function onclick(e: Event) {
select(selected_file_ids, file.id);
e.stopPropagation();
}
async function open() {
if (is_folder) {
change_file_path($current_file_path + file.name + '/');
} else {
const path_to_file = $current_file_path + file.name;
for (const display_id of $selected_display_ids) {
const ip = get_display_by_id(display_id, $displays)?.ip ?? null;
if (ip) {
await open_file(ip, path_to_file);
await update_screenshot(display_id);
}
}
}
}
</script>
<div class="flex flex-row h-{$current_height.file} w-full">
<div class="h-{$current_height.file} aspect-square max-w-15 flex">
<Button
disabled={!is_folder && get_file_type(file) === null}
title={!is_folder && get_file_type(file) === null ? 'Dateityp nicht unterstützt' : ''}
className="flex rounded-l-lg rounded-r-none {is_folder
? 'text-stone-450'
: 'text-stone-800'} w-full"
div_class="w-full"
bg={get_selectable_color_classes(
!is_folder && get_file_type(file) !== null,
{
bg: true
},
-50
)}
hover_bg={get_selectable_color_classes(
!is_folder,
{
bg: true
},
50
)}
active_bg={get_selectable_color_classes(
!is_folder,
{
bg: true
},
100
)}
click_function={(e) => {
open();
e.stopPropagation();
}}
>
{#if is_folder}
<ArrowRight class="size-full" strokeWidth="3" />
{:else if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[0].length !== 0}
<RefreshPlay className="size-full" />
{:else if get_file_type(file) !== null}
<Play class="size-full" strokeWidth="3" />
{:else}
<Ban class="size-full" strokeWidth="3" />
{/if}
</Button>
</div>
<div
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick(e);
}}
{onclick}
class="{get_selectable_color_classes(is_selected(file.id, $selected_file_ids), {
bg: true,
hover: true,
active: true,
text: true
})} rounded-r-lg transition-colors duration-200 gap-4 flex flex-row justify-between cursor-pointer group w-full min-w-0"
>
<div class="flex flex-row gap-2 min-w-0 w-full">
<div class="aspect-square rounded-md flex justify-center items-center p-2">
{#if is_folder}
<Folder class="size-full" />
{:else if file.thumbnail}
<div></div>
{:else if get_file_type(file)?.icon}
{@const Icon = get_file_type(file)?.icon}
<Icon class="size-full" />
{:else}
<FileIcon />
{/if}
</div>
<div class="content-center truncate select-none w-full" title={file.name}>
{file.name.includes('.') ? file.name.slice(0, file.name.lastIndexOf('.')) : file.name}
</div>
</div>
<div
class=" p-1 flex flex-row items-center gap-1 pr-1 {get_grayed_out_text_color_strings(
is_selected(file.id, $selected_file_ids)
)} duration-200 transition-colors"
>
{#if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[1].length !== 0}
<Button
className="h-8 aspect-square transition-colors duration-200 !p-1.5 text-stone-100"
bg="bg-red-500"
click_function={(e) => {
e.stopPropagation();
}}
>
<TriangleAlert class="size-full" />
</Button>
{:else if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[0].length !== 0}
<Button
className="h-8 aspect-square transition-colors duration-200 !p-1.5"
bg="bg-transparent"
hover_bg={get_selectable_color_classes(false, {
bg: true
})}
active_bg={get_selectable_color_classes(false, {
bg: true
})}
click_function={(e) => {
e.stopPropagation();
}}
>
<RefreshCcwDot class="size-full" />
</Button>
{/if}
<div
class="w-14 content-center text-center select-none text-xs whitespace-nowrap"
title={get_created_string(file.date_created, true)}
>
{get_created_string(file.date_created)}
</div>
<div
class="h-[70%] border {get_grayed_out_border_color_strings(
is_selected(file.id, $selected_file_ids)
)} duration-200 transition-colors my-1"
></div>
<div
class="w-12 content-center text-center select-none text-xs whitespace-nowrap truncate"
title={file.type}
>
{is_folder ? 'Ordner' : (get_file_type(file)?.display_name ?? '?')}
</div>
<div
class="h-[70%] border {get_grayed_out_border_color_strings(
is_selected(file.id, $selected_file_ids)
)} duration-200 transition-colors"
></div>
<div
class="w-12 content-center text-center select-none text-xs whitespace-nowrap"
title={get_file_size_display_string(file.size, 3)}
>
{get_file_size_display_string(file.size)}
</div>
</div>
</div>
</div>
@@ -1,69 +0,0 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { onDestroy, onMount } from 'svelte';
import Button from './Button.svelte';
import type { PopupContent } from '../ts/types';
import { fade } from 'svelte/transition';
let { content, close_function } = $props<{
content: PopupContent;
close_function: () => void;
}>();
function try_to_close() {
if (!content.closable || !content.open) return;
close_function();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
try_to_close();
}
}
onMount(() => {
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
</script>
{#if content.open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="absolute inset-0 backdrop-blur bg-white/10 flex justify-center items-center"
onclick={try_to_close}
transition:fade={{ duration: 100 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="bg-stone-800 rounded-2xl min-w-[30%] max-w-[80%] max-h-[80%] flex flex-col shadow-2xl/60 overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<div
class="text-2xl font-bold bg-stone-700 p-1.5 flex flex-row justify-between gap-6 w-full"
>
<div class="flex flex-row flex-1 gap-4 pl-2 py-1 items-center grow whitespace-nowrap min-w-0 flex-shrink-0 {content.title_class ?? ''}">
{#if content.title_icon}
{@const Icon = content.title_icon}
<Icon strokeWidth="2.8" class="flex-shrink-0" />
{/if}
<div class="flex-shrink-0">
{content.title}
</div>
</div>
<div class="flex aspect-square flex-shrink-0">
{#if content.closable}
<Button className="aspect-square !p-1.5" click_function={try_to_close}>
<X />
</Button>
{/if}
</div>
</div>
<div class="p-2 min-h-0 overflow-auto flex flex-col gap-2">
{@render content.snippet(content.snippet_arg)}
</div>
</div>
</div>
{/if}
@@ -1,70 +0,0 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { get_shifted_color } from '../ts/stores/ui_behavior';
import { onMount } from 'svelte';
let {
current_value = $bindable(),
current_valid = $bindable(),
className = '',
bg = 'bg-stone-750',
title,
placeholder = '',
is_valid_function = null
} = $props<{
current_value: string;
current_valid: boolean;
className?: string;
bg?: string;
title: string;
placeholder?: string;
is_valid_function?: ((input: string) => [boolean, string]) | null;
}>();
let focus_bg = get_shifted_color(bg, 100);
let focussed = $state(false);
let current_info = $state('');
function validate_input() {
if (!is_valid_function) return;
[current_valid, current_info] = is_valid_function(current_value.trim());
}
function get_highlighting_string(): string {
if (!is_valid_function) return '';
if (current_valid) {
return 'focus:inset-ring-2 focus:inset-ring-green-400';
} else {
return 'inset-ring-2 inset-ring-red-400';
}
}
onMount(() => {
validate_input();
});
</script>
<div class="flex flex-col {className}">
<div class="flex flex-row justify-between text-sm px-1">
<div class="text-stone-400">
{title}:
</div>
{#if is_valid_function && focussed}
<div class={current_valid ? "text-green-400" : "text-red-400"} transition:fade={{ duration: 100 }}>
{current_info}
</div>
{/if}
</div>
<input
bind:value={current_value}
type="text"
oninput={validate_input}
onfocus={() => {
focussed = true;
}}
onfocusout={() => {
focussed = false;
}}
class="{bg} focus:{focus_bg} outline-none py-2 px-3 rounded-xl transition-all duration-100 {get_highlighting_string()}"
{placeholder}
/>
</div>
@@ -1,7 +1,6 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { get_shifted_color } from '../ts/stores/ui_behavior';
import type { MenuOption } from '../ts/types';
import { get_shifted_color } from '$lib/ts/stores/ui_behavior';
import type { MenuOption } from '$lib/ts/types';
import { fade } from 'svelte/transition';
let {
@@ -11,12 +10,12 @@
active_bg = get_shifted_color(bg, 200),
disabled = false,
title = '',
click_function = (e: MouseEvent) => {},
menu_options = null,
click_function = () => {},
menu_options = [],
menu_class = 'right-0',
div_class = '',
children
} = $props<{
}: {
className?: string;
bg?: string;
hover_bg?: string;
@@ -24,19 +23,19 @@
disabled?: boolean;
title?: string;
click_function?: (e: MouseEvent) => void;
menu_options?: MenuOption[] | null;
menu_options?: MenuOption[];
menu_class?: string;
div_class?: string,
div_class?: string;
children?: any;
}>();
} = $props();
let menu_shown = $state(false);
let button_element: HTMLButtonElement;
let menu_element: HTMLDivElement;
let menu_element: HTMLDivElement | null = $state(null);
let position_bottom = $state(true);
function onclick(e: MouseEvent) {
if (menu_options !== null) {
if (menu_options.length !== 0) {
if (menu_shown) {
close_menu();
} else {
@@ -47,7 +46,7 @@
}
function getPolygon(): [number, number][] | null {
if (!button_element || !button_element) return null;
if (!button_element || !menu_element) return null;
const b = button_element.getBoundingClientRect();
const m = menu_element.getBoundingClientRect();
@@ -154,7 +153,7 @@
transition:fade={{ duration: 50 }}
class="absolute {position_bottom
? 'top-full'
: 'bottom-full'} {menu_class} z-100 my-1.5 min-w-64 rounded-xl backdrop-blur bg-black/20 border border-stone-400/10 shadow-xl/20 p-2 flex flex-col gap-2 text-stone-200 cursor-auto"
: 'bottom-full'} {menu_class} z-100 my-1.5 min-w-64 rounded-xl backdrop-blur bg-stone-800/45 border border-stone-400/10 shadow-xl/20 p-2 flex flex-col gap-2 text-stone-200 cursor-auto"
onclick={(e) => {
e.stopPropagation();
}}
@@ -166,8 +165,8 @@
? 'text-stone-500 cursor-not-allowed'
: 'hover:bg-white/35 active:bg-white/60 cursor-pointer ' +
option.class} rounded-lg p-2 transition-colors duration-200 select-none flex flex-row gap-2 items-center"
onclick={(e) => {
if (option.on_select) option.on_select();
onclick={async (e) => {
if (option.on_select) await option.on_select();
close_menu();
}}
>
@@ -2,10 +2,10 @@
import { GripHorizontal } from 'lucide-svelte';
import { dragHandle } from 'svelte-dnd-action';
let { bg, className = "" } = $props<{
let { bg, className = "" }: {
bg: string;
className?: string;
}>();
} = $props();
</script>
<div
@@ -0,0 +1,133 @@
<script lang="ts">
import { dragHandleZone, TRIGGERS } from 'svelte-dnd-action';
import {
dnd_flip_duration_ms,
get_selectable_color_classes,
is_display_drag,
is_group_drag
} from '$lib/ts/stores/ui_behavior';
import { cubicOut } from 'svelte/easing';
import { flip } from 'svelte/animate';
import DisplayObject from './DisplayObject.svelte';
import {
all_displays_of_group_selected,
set_select_for_group,
update_db_displays,
local_displays,
set_new_display_order
} from '$lib/ts/stores/displays';
import DNDGrip from '$lib/components/DNDGrip.svelte';
import { fade } from 'svelte/transition';
import type { Display, DisplayIdGroup, MenuOption } from '$lib/ts/types';
import { selected_display_ids } from '$lib/ts/stores/select';
import { liveQuery, type Observable } from 'dexie';
import { onMount } from 'svelte';
import { get_uuid } from '$lib/ts/utils';
let {
display_id_group,
get_display_menu_options,
close_pinned_display
}: {
display_id_group: DisplayIdGroup;
get_display_menu_options: (display_id: string) => MenuOption[];
close_pinned_display: () => void;
} = $props();
let all_selected: Observable<boolean> | undefined = $state();
$effect(() => {
const sdi = $selected_display_ids;
all_selected = liveQuery(() => all_displays_of_group_selected(display_id_group.id, sdi));
});
let hovering_selectable = $state(false);
async function select_all_of_this_group() {
const new_value = !($all_selected || false);
await set_select_for_group(display_id_group.id, new_value);
}
async function handle_consider(e: CustomEvent) {
const { items, info } = e.detail;
if (items.length !== 1 && info.trigger === TRIGGERS.DRAG_STARTED) {
$is_display_drag = true;
// add empty display group if its not just one group
if (!($local_displays.length === 1 && $local_displays[0].displays.length === 1)) {
$local_displays.push({
id: get_uuid(),
displays: []
});
}
}
set_new_display_order(display_id_group.id, items);
}
async function handle_finalize(e: CustomEvent) {
set_new_display_order(display_id_group.id, e.detail.items);
await update_db_displays();
$is_display_drag = false;
}
</script>
<div
transition:fade={{ duration: 100 }}
class="{get_selectable_color_classes(
$all_selected || false,
{
bg: true,
hover: hovering_selectable,
active: hovering_selectable,
text: true
},
-150,
-50
)} transition-colors duration-200 rounded-2xl flex flex-row cursor-pointer"
>
<div
class="flex flex-col min-w-0 pl-2 py-2 gap-2 w-full"
use:dragHandleZone={{
items: display_id_group.displays,
type: 'item',
flipDurationMs: dnd_flip_duration_ms,
dropTargetStyle: { outline: 'none' }
}}
onconsider={handle_consider}
onfinalize={handle_finalize}
>
{#each display_id_group.displays as display_id_object (display_id_object.id)}
<!-- Each Group -->
<section class="outline-none" role="figure">
<!-- animate:flip={{ duration: $is_group_drag ? 0 : dnd_flip_duration_ms, easing: cubicOut }} -->
<DisplayObject {display_id_object} {get_display_menu_options} {close_pinned_display} />
</section>
{/each}
{#if display_id_group.displays.length === 0}
<div class="min-h-10 h-full w-full pl-2 py-2 flex justify-center items-center">
Hier in leere neue Gruppe ablegen
</div>
{/if}
</div>
<div
class="flex items-center justify-center px-2"
onclick={select_all_of_this_group}
role="button"
tabindex="0"
onkeydown={async (e) => {
if (e.key === 'Enter' || e.key === ' ') await select_all_of_this_group();
}}
onmouseenter={() => (hovering_selectable = true)}
onmouseleave={() => (hovering_selectable = false)}
>
<DNDGrip
bg={get_selectable_color_classes(
$all_selected || false,
{
bg: true
},
-150
)}
/>
</div>
</div>
+145
View File
@@ -0,0 +1,145 @@
<script lang="ts">
import Button from '$lib/components/Button.svelte';
import {
current_height,
get_selectable_color_classes,
pinned_display_id
} from '$lib/ts/stores/ui_behavior';
import DNDGrip from '$lib/components/DNDGrip.svelte';
import { Menu, Pin, PinOff, VideoOff } from 'lucide-svelte';
import OnlineState from './OnlineState.svelte';
import type { Display, DisplayIdObject, MenuOption } from '$lib/ts/types';
import { is_selected, select, selected_display_ids } from '$lib/ts/stores/select';
import { get_display_by_id, screenshot_loop } from '$lib/ts/stores/displays';
import { change_file_path, current_file_path } from '$lib/ts/stores/files';
import { liveQuery } from 'dexie';
let {
display_id_object,
get_display_menu_options,
close_pinned_display
}: {
display_id_object: DisplayIdObject;
get_display_menu_options: (display_id: string) => MenuOption[];
close_pinned_display: () => void;
} = $props();
let hovering_unselectable = $state(false);
let display = liveQuery(() => get_display_by_id(display_id_object.id));
async function onclick(e: Event) {
e.stopPropagation();
select(selected_display_ids, display_id_object.id, 'toggle');
// force file view update
await change_file_path($current_file_path);
}
async function on_preview_click(e: MouseEvent) {
if ($pinned_display_id === display_id_object.id) {
close_pinned_display();
} else {
$pinned_display_id = display_id_object.id;
}
screenshot_loop(display_id_object.id);
e.stopPropagation();
}
</script>
<div
data-testid="display"
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick(e);
}}
{onclick}
class="p-1 {get_selectable_color_classes(is_selected(display_id_object.id, $selected_display_ids), {
bg: true,
hover: true,
active: !hovering_unselectable,
text: true
})} rounded-xl flex flex-row justify-between h-{$current_height.display} transition-colors duration-100 gap-2 cursor-pointer w-full text-stone-200"
>
<div class="flex flex-row gap-4 min-w-0 flex-1">
<!-- Left Preview Screen -->
<button
class="group relative aspect-video bg-stone-800 h-full rounded-lg overflow-hidden cursor-pointer text-stone-200 transition-colors duration-200"
onmouseenter={() => (hovering_unselectable = true)}
onmouseleave={() => (hovering_unselectable = false)}
onclick={on_preview_click}
>
<div class="flex h-full w-full items-center justify-center">
{#if $pinned_display_id === display_id_object.id}
<div class="size-[50%]">
<Pin class="size-full" />
</div>
{:else if $display && $display.preview.url}
<img src={$display.preview.url} alt="preview" class="w-full object-cover bg-black" />
{:else}
<!-- No Signal -->
<div class="size-full bg-black flex justify-center items-center">
<VideoOff class="size-[30%]" />
</div>
{/if}
</div>
<!-- Hover Effect -->
<span
class="pointer-events-none absolute inset-0 {$pinned_display_id === display_id_object.id
? 'bg-stone-700'
: 'bg-stone-700/70'} opacity-0 transition-opacity duration-200 flex items-center justify-center group-hover:opacity-100"
>
{#if $pinned_display_id === display_id_object.id}
<PinOff class="size-[50%]" aria-hidden="true" />
{:else}
<Pin class="size-[50%]" aria-hidden="true" />
{/if}
</span>
</button>
<!-- Middle Text Block -->
<div
class="h-full flex flex-col justify-center gap-1 select-none
min-w-0 basis-0 flex-1"
>
<div class="text-xl font-bold truncate w-full" title={$display?.name ?? ''}>
{$display?.name ?? ''}
</div>
<OnlineState
selected={is_selected(display_id_object.id, $selected_display_ids)}
status={$display?.status ?? null}
/>
</div>
</div>
<!-- Right Controls -->
<div class="flex flex-row h-full items-center gap-2 pr-3">
<DNDGrip
bg={get_selectable_color_classes(is_selected(display_id_object.id, $selected_display_ids), {
bg: true
})}
/>
<div
role="figure"
onmouseenter={() => (hovering_unselectable = true)}
onmouseleave={() => (hovering_unselectable = false)}
>
<Button
bg="bg-transparent"
hover_bg={get_selectable_color_classes(is_selected(display_id_object.id, $selected_display_ids), {
bg: true
})}
active_bg={get_selectable_color_classes(is_selected(display_id_object.id, $selected_display_ids), {
bg: true
})}
click_function={(e) => {
e.stopPropagation();
}}
menu_options={get_display_menu_options(display_id_object.id)}
>
<Menu />
</Button>
</div>
</div>
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
let {
children,
bg = 'bg-stone-750',
fg = 'text-stone-200',
className = ''
}: {
children: any;
bg?: string;
fg?: string;
className?: string;
} = $props();
</script>
<div
class="{bg} {fg} {className} rounded-lg py-0.5 px-1 inline-block truncate min-w-0 max-w-full align-middle my-[1.5px]"
>
{@render children()}
</div>
+385
View File
@@ -0,0 +1,385 @@
<script lang="ts">
import { ArrowRight, Ban, FileIcon, Folder, Play } from 'lucide-svelte';
import {
current_height,
get_selectable_color_classes,
get_shifted_color
} from '$lib/ts/stores/ui_behavior';
import Button from '$lib/components/Button.svelte';
import {
supported_file_type_icon,
type Inode,
get_file_primary_key,
type FileOnDisplay,
type FileTransferTask,
is_folder
} from '$lib/ts/types';
import {
is_selected,
select,
selected_display_ids,
selected_file_ids
} from '$lib/ts/stores/select';
import {
change_file_path,
current_file_path,
get_date_mapping,
get_folder_elements,
get_missing_colliding_display_ids
} from '$lib/ts/stores/files';
import RefreshPlay from '../svgs/RefreshPlay.svelte';
import { get_file_size_display_string, get_file_type } from '$lib/ts/utils';
import { open_file } from '$lib/ts/api_handler';
import { get_display_by_id, run_on_all_selected_displays } from '$lib/ts/stores/displays';
import { get_thumbnail_url } from '$lib/ts/stores/thumbnails';
import { liveQuery, type Observable } from 'dexie';
import { db } from '$lib/ts/database';
import { file_transfer_tasks } from '$lib/ts/file_transfer_handler';
let { file, not_interactable = false }: { file: Inode; not_interactable?: boolean } = $props();
let file_primary_key = $derived(get_file_primary_key(file));
let missing_colliding_displays_ids:
| Observable<{ missing: string[]; colliding: string[] }>
| undefined = $state();
$effect(() => {
const s = $selected_file_ids;
missing_colliding_displays_ids = liveQuery(() => get_missing_colliding_display_ids(file, s));
});
let file_size: Observable<number> | undefined = $state();
$effect(() => {
const f = file;
file_size = liveQuery(() => get_size_recursively(f));
});
let file_transfer_task: FileTransferTask | null = $derived(
$file_transfer_tasks.hasOwnProperty(file_primary_key)
? $file_transfer_tasks[file_primary_key]
: null
);
let loading_finished = $state(false);
let previous_loading_state = $state(false);
$effect(() => {
const ftt = file_transfer_task;
if (previous_loading_state && !ftt) {
loading_finished = true;
setTimeout(() => (loading_finished = false), 200);
}
previous_loading_state = !!ftt;
});
let thumbnail_url = liveQuery(() => get_thumbnail_url(file_primary_key));
let date_mapping: Observable<Record<string, Date>> = liveQuery(() =>
get_date_mapping(file_primary_key)
);
const file_is_folder = $derived(is_folder(file));
function get_created_info(date_mapping: Record<string, Date> | undefined, full_string = false) {
if (!date_mapping) return '';
const keys = Object.keys(date_mapping);
if (keys.length === 0) return '';
if (keys.length === 1) return get_formated_created_string(date_mapping[keys[0]], full_string);
let out = '';
let is_different = false;
const first_formated_created_string = get_formated_created_string(
date_mapping[keys[0]],
full_string
);
out += `${keys[0]}: ${first_formated_created_string}`;
for (const key of keys.splice(0, 1)) {
const current_formated_created_string = get_formated_created_string(
date_mapping[key],
full_string
);
if (!is_different && current_formated_created_string !== first_formated_created_string)
is_different = true;
out += `\n${key}: ${current_formated_created_string}`;
}
if (full_string) {
return is_different ? out : first_formated_created_string;
} else {
return is_different ? 'versch.' : first_formated_created_string;
}
}
function get_formated_created_string(date_object: Date, full_string: boolean) {
if (full_string) {
return (
get_formated_date_string(date_object, true) + ' ' + get_formated_time_string(date_object)
);
} else if (date_object.toDateString() === new Date().toDateString()) {
return get_formated_time_string(date_object);
} else {
return get_formated_date_string(date_object);
}
}
function get_formated_time_string(date_object: Date) {
return `${date_object.getHours().toString().padStart(2, '0')}:${date_object.getMinutes().toString().padStart(2, '0')}`;
}
function get_formated_date_string(date_object: Date, full_year = false) {
return `${date_object.getDate().toString().padStart(2, '0')}.${(date_object.getMonth() + 1).toString().padStart(2, '0')}.${date_object
.getFullYear()
.toString()
.slice(full_year ? 0 : 2)}`;
}
function get_grayed_out_text_color_strings(is_selected: boolean): string {
if (not_interactable) return 'text-stone-400';
if (!!file_transfer_task) return 'text-white/20';
const color = is_selected ? 'text-stone-600' : 'text-stone-400';
const factor = is_selected ? -1 : 1;
return `${color} group-hover:${get_shifted_color(color, factor * 100)} group-active:${get_shifted_color(color, factor * 150)}`;
}
function get_grayed_out_border_color_strings(is_selected: boolean): string {
if (not_interactable) return 'border-stone-550';
if (!!file_transfer_task) return 'border-white/10';
const color = is_selected ? 'border-stone-450' : 'border-stone-550';
const factor = is_selected ? 1 : 1;
return `${color} group-hover:${get_shifted_color(color, factor * 100)} group-active:${get_shifted_color(color, factor * 150)}`;
}
function onclick(e: Event) {
if (not_interactable || !!file_transfer_task) return;
select(selected_file_ids, file_primary_key, 'toggle');
e.stopPropagation();
}
async function open() {
if (file_is_folder) {
await change_file_path($current_file_path + file.name + '/');
} else {
const path_to_file = $current_file_path + file.name;
await run_on_all_selected_displays((d) => open_file(d.ip, path_to_file));
}
}
function get_main_classes(): string {
let out = '';
if (loading_finished) {
out += 'bg-stone-500 text-white/30';
} else if (!!file_transfer_task) {
out += 'bg-stone-700 text-white/30';
} else {
out += get_selectable_color_classes(
!not_interactable && is_selected(file_primary_key, $selected_file_ids),
{
bg: true,
hover: !not_interactable,
active: !not_interactable,
text: true
}
);
}
if (not_interactable) {
out += ' rounded-lg';
} else if (!!file_transfer_task) {
out += ' rounded-r-lg';
} else {
out += ' rounded-r-lg cursor-pointer';
}
return out;
}
function get_total_percentage(ftt: FileTransferTask): number {
let total_percentage: number;
if (ftt.data.type === 'upload') {
total_percentage = ftt.loading_data.percentage;
} else {
const percentage_array = ftt.data.destination_display_data.map(
(dd) => dd.loading_data.percentage
);
total_percentage =
(ftt.loading_data.percentage + percentage_array.reduce((total, n) => total + n, 0)) /
(1 + percentage_array.length);
}
return Math.min(total_percentage, 100);
}
async function get_size_recursively(file: Inode): Promise<number> {
if (is_folder(file)) {
const folder_elements = await get_folder_elements(
file.path + file.name + '/',
$selected_display_ids
);
let out: number = 0;
for (const el of folder_elements) {
out += await get_size_recursively(el);
}
return out;
} else {
return file.size;
}
}
</script>
<div
data-testid="inode"
class="flex flex-row h-{$current_height.file} w-full {loading_finished
? 'scale-105'
: ''} transition-[scale] duration-300"
>
{#if !not_interactable}
<div class="h-{$current_height.file} aspect-square max-w-15 flex">
<Button
disabled={!file_is_folder && get_file_type(file) === null}
title={!file_is_folder && get_file_type(file) === null ? 'Dateityp nicht unterstützt' : ''}
className="flex rounded-l-lg rounded-r-none {file_is_folder
? 'text-stone-450'
: 'text-stone-800'} w-full"
div_class="w-full"
bg={get_selectable_color_classes(
!file_is_folder && get_file_type(file) !== null,
{
bg: true
},
-50
)}
hover_bg={get_selectable_color_classes(
!file_is_folder,
{
bg: true
},
50
)}
active_bg={get_selectable_color_classes(
!file_is_folder,
{
bg: true
},
100
)}
click_function={(e) => {
open();
e.stopPropagation();
}}
>
{#if file_is_folder}
<ArrowRight class="size-full" strokeWidth="3" />
{:else if $missing_colliding_displays_ids && $missing_colliding_displays_ids.missing.length !== 0}
<RefreshPlay className="size-full" />
{:else if get_file_type(file) !== null}
<Play class="size-full" strokeWidth="3" />
{:else}
<Ban class="size-full" strokeWidth="3" />
{/if}
</Button>
</div>
{/if}
<div
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick(e);
}}
{onclick}
class="{get_main_classes()} relative transition-colors duration-200 gap-4 flex flex-row justify-between group w-full min-w-0"
>
{#if !!file_transfer_task}
<div
class="absolute pointer-events-none inset-y-0 left-0 transition-[width] duration-400 bg-stone-600 rounded-r-lg"
style={`width: ${get_total_percentage(file_transfer_task)}%;`}
></div>
{/if}
<div class="flex flex-row gap-2 min-w-0 w-full z-10">
<div class="aspect-square rounded-md flex justify-center items-center">
{#if file_is_folder}
<Folder class="size-full p-2" />
{:else if $thumbnail_url || null}
<img
src={$thumbnail_url || null}
alt="ERR"
class="object-contain size-full select-none block p-1 rounded-lg text-center content-center text-red-300"
draggable="false"
/>
{:else if supported_file_type_icon[get_file_type(file)?.display_name || '']}
{@const Icon = supported_file_type_icon[get_file_type(file)?.display_name || '']}
<Icon class="size-full p-2" />
{:else}
<FileIcon class="size-full p-2" />
{/if}
</div>
<div class="content-center truncate select-none w-full" title={file.name}>
{file.name.includes('.') && !file_is_folder && get_file_type(file)
? file.name.slice(0, file.name.lastIndexOf('.'))
: file.name}
</div>
</div>
<div
class=" p-1 flex flex-row items-center gap-1 pr-1 z-10 {get_grayed_out_text_color_strings(
is_selected(file_primary_key, $selected_file_ids)
)} duration-200 transition-colors"
>
<!-- {#if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[1].length !== 0}
<Button
className="h-8 aspect-square transition-colors duration-200 !p-1.5 text-stone-100"
bg="bg-red-500"
click_function={(e) => {
e.stopPropagation();
}}
>
<TriangleAlert class="size-full" />
</Button>
{:else if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[0].length !== 0}
<Button
className="h-8 aspect-square transition-colors duration-200 !p-1.5"
bg="bg-transparent"
hover_bg={get_selectable_color_classes(false, {
bg: true
})}
active_bg={get_selectable_color_classes(false, {
bg: true
})}
click_function={(e) => {
e.stopPropagation();
}}
>
<RefreshCcwDot class="size-full" />
</Button>
{/if} -->
<div
class="w-14 content-center text-center select-none text-xs whitespace-nowrap"
title={get_created_info($date_mapping, true)}
>
{get_created_info($date_mapping)}
</div>
<div
class="h-[70%] border {get_grayed_out_border_color_strings(
is_selected(file_primary_key, $selected_file_ids)
)} duration-200 transition-colors my-1"
></div>
<div
class="w-12 content-center text-center select-none text-xs whitespace-nowrap truncate"
title={file.type}
>
{file_is_folder ? 'Ordner' : (get_file_type(file)?.display_name ?? '?')}
</div>
<div
class="h-[70%] border {get_grayed_out_border_color_strings(
is_selected(file_primary_key, $selected_file_ids)
)} duration-200 transition-colors"
></div>
<div
class="w-12 content-center text-center select-none text-xs whitespace-nowrap"
title={get_file_size_display_string($file_size ?? -1, 3)}
>
{get_file_size_display_string($file_size ?? -1)}
</div>
</div>
</div>
</div>
@@ -0,0 +1,66 @@
<script lang="ts">
import type { NumberSetting } from '$lib/ts/types';
import { ChevronDown, ChevronUp, Minus, Plus } from 'lucide-svelte';
import Button from './Button.svelte';
let {
number_setting,
on_change,
className = '',
disabled = false,
}: {
number_setting: NumberSetting;
on_change: (new_value: number) => void;
className?: string;
disabled?: boolean;
} = $props();
let el: HTMLInputElement | null = null;
function increase() {
if (!el) return;
el.stepUp();
el.dispatchEvent(new Event('input', { bubbles: true }));
on_change(Number(el.value));
}
function decrease() {
if (!el) return;
el.stepDown();
el.dispatchEvent(new Event('input', { bubbles: true }));
on_change(Number(el.value));
}
function on_blur() {
if (!el) return;
let value = Number(el.value);
if (value > number_setting.max) value = number_setting.max;
else if (value < number_setting.min) value = number_setting.min;
el.value = String(value);
on_change(value);
}
</script>
<div class="flex flex-row {className}">
<Button click_function={increase} className="rounded-r-none" disabled={disabled}>
<ChevronUp />
</Button>
<input
bind:this={el}
class="bg-stone-700 focus:bg-stone-550 focus:outline-none transition-colors duration-200 py-2 px-4 w-15 text-center
{disabled ? 'text-stone-500 cursor-not-allowed' : 'hover:bg-stone-600'}
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
type="number"
disabled={disabled}
max={number_setting.max}
min={number_setting.min}
value={number_setting.now}
step={number_setting.step}
onblur={on_blur}
/>
<Button click_function={decrease} className="rounded-l-none" disabled={disabled}>
<ChevronDown />
</Button>
</div>
@@ -1,17 +1,24 @@
<script lang="ts">
let { selected, status, className = "" } = $props<{
selected: boolean;
status: string;
className?: string;
}>();
import { type DisplayStatus } from '$lib/ts/types';
import { display_status_to_info } from '$lib/ts/utils';
function get_text_color(selected: boolean, status: string) {
let {
selected,
status,
className = ''
}: {
selected: boolean;
status: DisplayStatus;
className?: string;
} = $props();
function get_text_color(selected: boolean, status: DisplayStatus) {
switch (status) {
case 'Online':
case 'app_online':
return selected ? 'text-green-700' : 'text-green-400';
case 'Lädt':
case 'app_offline':
return selected ? 'text-amber-700' : 'text-amber-400';
case 'Offline':
case 'host_offline':
return selected ? 'text-red-700' : 'text-red-400';
default:
return selected ? 'text-stone-700' : 'text-stone-400';
@@ -20,5 +27,5 @@
</script>
<div class="{get_text_color(selected, status)} {className} transition-colors duration-100">
{status}
{display_status_to_info(status)}
</div>
@@ -0,0 +1,74 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import Button from './Button.svelte';
import type { PopupContent } from '$lib/ts/types';
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
let {
content,
close_function,
className = '',
snippet_container_class = ''
}: {
content: PopupContent;
close_function: () => void;
className?: string;
snippet_container_class?: string;
} = $props();
function try_to_close() {
if (!content.open) return;
close_function();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
try_to_close();
}
}
onMount(() => {
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
</script>
{#if content.open}
<div
class="absolute inset-0 backdrop-blur flex justify-center items-center z-50 {className}"
transition:fade={{ duration: 100 }}
>
<div
class="bg-stone-800 rounded-2xl min-w-[30%] max-w-[90%] max-h-[85%] flex flex-col shadow-2xl/60 overflow-hidden {content.window_class ??
''}"
>
{#if content.title}
<div class="font-bold bg-stone-700 p-1.5 flex flex-row justify-between gap-8 w-full">
<div
class="flex flex-row flex-1 gap-3 pl-2 py-1 items-center grow whitespace-nowrap min-w-0 shrink-0 text-lg {content.title_class ??
''}"
>
{#if content.title_icon}
{@const Icon = content.title_icon}
<Icon strokeWidth="2" class="shrink-0" />
{/if}
<div class="shrink-0">
{content.title}
</div>
</div>
<div class="flex aspect-square shrink-0">
<Button className="aspect-square p-1.5!" click_function={try_to_close}>
<X />
</Button>
</div>
</div>
{/if}
<div class="p-2 min-h-0 overflow-auto flex flex-col gap-2 {snippet_container_class}">
{#if content.snippet}
{@render content.snippet(content.snippet_arg ?? '')}
{/if}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,134 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { get_shifted_color } from '$lib/ts/stores/ui_behavior';
import { onMount } from 'svelte';
import { selected_display_ids } from '$lib/ts/stores/select';
let {
current_value = $bindable(),
current_valid = $bindable(),
className = '',
bg = 'bg-stone-750',
title,
placeholder = '',
is_valid_function = null,
focused_on_start = false,
extension = null,
enter_mode = 'none',
enter_function = null
}: {
current_value: string;
current_valid: boolean;
className?: string;
bg?: string;
title: string;
placeholder?: string;
is_valid_function?: ((input: string) => [boolean, string] | Promise<[boolean, string]>) | null;
focused_on_start?: boolean;
extension?: string | null;
enter_mode?: 'none' | 'focus_next' | 'submit';
enter_function?: (() => void) | null;
} = $props();
let focus_bg = $derived(get_shifted_color(bg, 100));
let focused: boolean = $state(false);
let current_info = $state('');
let input_element: HTMLInputElement;
async function validate_input() {
if (!is_valid_function) return;
[current_valid, current_info] = await is_valid_function(current_value.trim());
}
function get_highlighting_string(): string {
if (!is_valid_function) return '';
if (current_valid) {
return 'focus-within:inset-ring-2 focus-within:inset-ring-green-400';
} else {
return 'inset-ring-2 inset-ring-red-400';
}
}
function focus_next_element() {
const focusable = Array.from(document.querySelectorAll<HTMLElement>('input')).filter(
(el) => !el.hasAttribute('disabled')
);
const index = focusable.indexOf(input_element);
if (index !== -1 && index < focusable.length - 1) {
focusable[index + 1].focus();
}
}
function handle_keydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || !current_valid) return;
if (enter_mode === 'focus_next') {
event.preventDefault();
focus_next_element();
} else if (enter_mode === 'submit' && enter_function) {
event.preventDefault();
enter_function();
}
}
onMount(async () => {
await validate_input();
if (focused_on_start && input_element) input_element.focus();
selected_display_ids.subscribe(async () => {
await validate_input();
});
});
</script>
<div class="flex flex-col {className} relative">
<div class="flex flex-row justify-between text-sm px-1 gap-4">
<div class="text-stone-400">
{title}:
</div>
{#if is_valid_function && focused}
<div
class={current_valid ? 'text-green-400' : 'text-red-400'}
transition:fade={{ duration: 100 }}
>
{current_info}
</div>
{/if}
</div>
<!-- <input
bind:this={input_element}
bind:value={current_value}
bind:focused
onkeydown={handle_keydown}
type="text"
oninput={validate_input}
class="{bg} focus:{focus_bg} outline-none py-2 px-3 rounded-xl transition-all duration-100 {get_highlighting_string()}"
{placeholder}
/> -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
onclick={() => {
input_element.focus();
}}
class="{bg} focus-within:{focus_bg} flex items-center rounded-xl {get_highlighting_string()} cursor-text"
>
<input
bind:this={input_element}
bind:value={current_value}
bind:focused
onkeydown={handle_keydown}
oninput={validate_input}
type="text"
class=" outline-none py-2 px-3 transition-all duration-100 grow group"
{placeholder}
/>
{#if extension}
<span class="pr-3 text-stone-400 select-none pointer-events-none">
{extension}
</span>
{/if}
</div>
</div>
-1
View File
@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.
@@ -1,5 +1,5 @@
<script lang="ts">
let {className = ''} = $props<{className?: string}>();
let { className = '' }: { className?: string } = $props();
</script>
<svg
+327
View File
@@ -0,0 +1,327 @@
import { notifications } from './stores/notification';
import {
is_folder,
to_display_status,
type DisplayStatus,
type Inode,
type RequestResponse,
type ShellCommandResponse,
type TreeElement
} from './types';
import { dev } from '$app/environment';
import { get_sanitized_file_url } from './utils';
import { online_displays } from './stores/displays';
import { get } from 'svelte/store';
import { update_display_status } from './main';
export async function get_screenshot(ip: string): Promise<Blob | null> {
const options = { method: 'PATCH' };
const response = await request_display(ip, '/takeScreenshot', options);
if (!response.ok || !response.blob) return null;
return response.blob;
}
export async function open_file(ip: string, path_to_file: string): Promise<void> {
const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } };
await request_display(ip, get_sanitized_file_url(path_to_file), options);
}
export async function send_keyboard_input(
ip: string,
inputs: { key: string; action: 'press' | 'release' }[]
): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
inputs
})
};
await request_display(ip, '/keyboardInput', options);
}
export async function show_html(ip: string, html: string): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: html
})
};
await request_display(ip, '/showHTML', options);
}
export async function get_file_data(
ip: string,
path: string
): Promise<{ folder_element: Inode; date_created: Date }[] | null> {
interface FileInfo {
name: string;
type: string;
size: string;
created: string;
}
const command = `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do
typ=$(file -b --mime-type -- "$f")
size=$(stat -c '%s' -- "$f")
created=$(stat -c '%w' -- "$f")
[ "$created" = "-" ] && created=$(stat -c '%y' -- "$f")
jq -n --arg name "$f" --arg type "$typ" --arg size "$size" --arg created "$created" \
'{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n'
echo
done
`;
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (json_response.exitCode === 0 && json_response.stdout.trim() === '') return [];
if (handle_shell_error(ip, json_response, command, true)) return null;
if (json_response.stdout.trim() === '') return null;
const response: FileInfo[] = json_response.stdout
.trim()
.split('\n')
.filter(Boolean)
.map((line: string) => JSON.parse(line) as FileInfo);
const folder_element_list: { folder_element: Inode; date_created: Date }[] = [];
for (const response_element of response) {
// filter hidden files (start with '.' -> './.config')
if (response_element.name.charAt(2) === '.') continue;
const folder_element: Inode = {
path: path,
name: response_element.name.slice(2), // remove "./"
type: response_element.type,
size: Number(response_element.size),
thumbnail: null
};
if (is_folder(folder_element)) folder_element.size = 0;
folder_element_list.push({ folder_element, date_created: new Date(response_element.created) });
}
return folder_element_list;
}
export async function get_file_tree_data(ip: string, path: string): Promise<TreeElement[] | null> {
const command = `cd ".${path}" && tree -Js`;
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (handle_shell_error(ip, json_response, command, true)) return null;
const tree_element: TreeElement | null = JSON.parse(json_response.stdout.trim())[0] || null;
return tree_element?.contents || null;
}
export async function create_path(ip: string, path: string): Promise<void> {
const command = `mkdir -p ".${path}"`;
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function rename_file(
ip: string,
path: string,
old_file_name: string,
new_file_name: string
): Promise<void> {
const command: string = `cd ".${path}" && mv "${old_file_name}" "${new_file_name}"`;
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function delete_files(
ip: string,
current_path: string,
file_names: string[]
): Promise<void> {
let command: string = `cd ".${current_path}"`;
for (const file_name of file_names) {
command += ` && rm -r "${file_name}"`;
}
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function show_blackscreen(ip: string): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: '<p></p>'
})
};
await request_display(ip, '/showHTML', options);
}
export async function get_thumbnail_blob(ip: string, path_to_file: string): Promise<Blob | null> {
const raw_response = await request_display(
ip,
get_sanitized_file_url(path_to_file, true),
{ method: 'GET' },
[415]
);
if (!raw_response.ok || !raw_response.blob) return null;
return raw_response.blob;
}
export async function ping_ip(ip: string): Promise<DisplayStatus> {
const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' });
if (!raw_response.ok || !raw_response.json) return null;
const status = raw_response.json.status;
if (typeof status === 'string') {
return to_display_status(status);
}
return null;
}
async function request_display(
ip: string,
api_route: string,
options: { method: string; headers?: Record<string, string>; body?: string },
supress_error_handling_http_codes: number[] = []
): Promise<RequestResponse> {
const url = `http://${ip}:1323/api${api_route}`;
const current_online_displays = get(online_displays);
if (!current_online_displays.map((d) => d.ip).includes(ip)) return { ok: false };
const response = await request(url, options, supress_error_handling_http_codes);
if (!response.ok && response.http_code === 408) {
// Network error -> device possibly not longer online -> test status and throw no error if its offline
const possible_displays = current_online_displays.filter((d) => d.ip === ip);
for (const display of possible_displays) {
const current_status = await update_display_status(display);
if (current_status === 'app_online') {
console.error(`No response from ${url}`);
notifications.push('error', 'Netzwerk-Fehler bei API-Anfrage', `${url}`);
}
}
}
return response;
}
async function request_control(
api_route: string,
options: { method: string; headers?: Record<string, string>; body?: string }
): Promise<RequestResponse> {
const url = `${dev ? 'http://127.0.0.1:8080' : window.location.origin}/api${api_route}`;
return await request(url, options);
}
async function request(
url: string,
options: { method: string; headers?: Record<string, string>; body?: string },
supress_error_handling_http_codes: number[] = []
): Promise<RequestResponse> {
try {
const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`;
if (dev) {
console.debug('Sending request: ', url + cache_buster, 'with', options.body ?? 'none');
}
const response = await fetch(url + cache_buster, options);
if (response.ok || supress_error_handling_http_codes.includes(response.status)) {
const contentType = response.headers.get('content-type') || '';
let request_response: RequestResponse;
if (!contentType.includes('application/json')) {
const blob: Blob = await response.blob();
request_response = { ok: response.ok, http_code: response.status, blob: blob };
} else {
const json: Record<string, unknown> = await response.json();
request_response = { ok: response.ok, http_code: response.status, json: json };
if (dev) {
console.debug(request_response);
}
}
return request_response;
}
let error_description = url;
if (response.headers.get('content-type')?.includes('application/json')) {
try {
const json: { description: string } = await response.json();
error_description += '\n' + json.description;
} catch {
error_description += '\nCould not parse error description';
}
}
notifications.push(
'error',
`Fehler bei API-Anfrage`,
`\nHTTP: ${response.status}\n${error_description}`
);
} catch (error: unknown) {
if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) {
if (dev) {
console.warn('Request failed - Is the targeted device online?');
}
return { ok: false, http_code: 408 };
} else {
console.error(url, error);
notifications.push('error', `Fataler Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
}
}
return { ok: false };
}
function handle_shell_error(
ip: string,
shell_response: ShellCommandResponse,
shell_command: string,
command_includs_cd: boolean
): boolean {
if (shell_response.exitCode !== 0) {
if (
command_includs_cd &&
shell_response.stderr &&
/bash: line \d+: cd: .+: No such file or directory/.test(shell_response.stderr)
) {
if (dev) {
console.debug('current file_path does not exist on display:', ip);
}
return true;
}
console.error(shell_response);
notifications.push(
'error',
`Fehler in API-Shell`,
`${ip}\n${shell_command}\nFehler (${shell_response.exitCode}): ${shell_response.stderr}`
);
return true;
}
return false;
}
async function run_shell_command(ip: string, command: string): Promise<RequestResponse> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: command
})
};
return await request_display(ip, '/shellCommand', options);
}
export async function shutdown(ip: string): Promise<RequestResponse> {
return await run_shell_command(ip, 'xfce4-session-logout --halt');
}
export async function startup(mac: string): Promise<RequestResponse> {
return await request_control(`/wakeOnLan`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mac_address: mac })
});
}
+46
View File
@@ -0,0 +1,46 @@
import Dexie, { type Table } from 'dexie';
import type { Display, DisplayGroup, FileOnDisplay, Inode } from './types';
export class FileDatabase extends Dexie {
files!: Table<Inode, [string, string, number, string]>;
files_on_display!: Table<FileOnDisplay, [string, string]>;
displays!: Table<Display, string>;
display_groups!: Table<DisplayGroup, string>;
constructor() {
super('FileDatabase');
this.version(1).stores({
files: `
[path+name+size+type],
path,
name,
size,
type,
thumbnail
`,
files_on_display: `
[display_id+file_primary_key],
display_id,
file_primary_key,
date_created
`,
displays: `
id,
ip,
mac,
position,
preview,
group_id,
name,
status
`,
display_groups: `
id,
position
`
});
}
}
export const db = new FileDatabase();
@@ -0,0 +1,520 @@
import { get, writable, type Writable } from 'svelte/store';
import { db } from './database';
import { get_display_by_id } from './stores/displays';
import {
create_path_on_all_selected_displays,
get_folder_elements,
get_file_by_id,
remove_all_files_without_display,
remove_file_from_display_recusively
} from './stores/files';
import { notifications } from './stores/notification';
import { generate_thumbnail } from './stores/thumbnails';
import {
get_file_primary_key,
is_folder,
type FileLoadingData,
type FileOnDisplay,
type FileTransferTask,
type Inode,
type ShortDisplay
} from './types';
import { get_sanitized_file_url, get_uuid, make_valid_name } from './utils';
const START_LOADING_DATA = {
percentage: 0,
bytes_per_second: 0,
seconds_until_finish: -1
};
export const file_transfer_tasks: Writable<Record<string, FileTransferTask>> = writable<
Record<string, FileTransferTask>
>({});
let is_processing: boolean = false;
export async function add_upload(
file_list: FileList,
selected_display_ids: string[],
current_file_path: string
) {
if (file_list.length === 0) return console.warn('Upload canceled: no selected files');
await create_path_on_all_selected_displays(current_file_path, selected_display_ids);
const used_file_names: string[] = await (
await get_folder_elements(current_file_path, selected_display_ids)
).map((e) => e.name);
for (const file of file_list) {
const file_name = generate_valid_file_name(file.name, used_file_names);
used_file_names.push(file_name);
const db_file: Inode = {
path: current_file_path,
name: file_name,
size: file.size,
type: file.type,
thumbnail: null
};
const file_primary_key = get_file_primary_key(db_file);
if (Object.prototype.hasOwnProperty.call(file_transfer_tasks, file_primary_key))
return show_already_in_tasks_error(db_file, 'upload'); // file is already in task
await db.files.put(db_file);
const upload_task_promises = selected_display_ids.map(async (display_id) => {
const display_ip = (await get_display_by_id(display_id))?.ip;
if (!display_ip) return;
const db_file_on_display: FileOnDisplay = {
display_id,
file_primary_key,
date_created: new Date()
};
await db.files_on_display.put(db_file_on_display);
const new_task = {
data: {
type: 'upload' as const,
file
},
display: {
id: display_id,
ip: display_ip
},
path: current_file_path,
file_name: file_name,
loading_data: START_LOADING_DATA,
bytes_total: file.size
};
file_transfer_tasks.update((tasks) => ({
...tasks,
[file_primary_key]: new_task
}));
});
await Promise.all(upload_task_promises);
}
await start_task_processing();
}
export async function add_sync_recursively(
selected_file_id: string,
selected_display_ids: string[]
) {
const file_data = await find_file_data_on_active_selected_display(
selected_file_id,
selected_display_ids
);
if (!file_data) return console.warn('Sync canceled: no file_data');
if (is_folder(file_data.file)) {
const new_path = file_data.file.path + file_data.file.name + '/';
const elements_in_folder = await get_folder_elements(new_path, selected_display_ids);
if (elements_in_folder.length === 0) {
await create_path_on_all_selected_displays(new_path, selected_display_ids);
} else {
for (const el of elements_in_folder) {
await add_sync_recursively(get_file_primary_key(el), selected_display_ids);
}
}
return; // cannot sync folder
}
if (file_data.short_displays_without_file.length === 0) return; // file is present on all selected displays
if (Object.prototype.hasOwnProperty.call(file_transfer_tasks, selected_file_id))
return show_already_in_tasks_error(file_data.file, 'sync'); // file is already in task
await create_path_on_all_selected_displays(file_data.file.path, selected_display_ids);
const new_task: FileTransferTask = {
data: {
type: 'sync',
destination_display_data: file_data.short_displays_without_file.map((display) => ({
display,
loading_data: START_LOADING_DATA
}))
},
display: file_data.short_display_with_file,
path: file_data.file.path,
file_name: file_data.file.name,
loading_data: START_LOADING_DATA,
bytes_total: file_data.file.size
};
file_transfer_tasks.update((tasks) => ({
...tasks,
[selected_file_id]: new_task
}));
const display_ids_without_file = file_data.short_displays_without_file.map((d) => d.id);
const new_fods: FileOnDisplay[] = display_ids_without_file.map((display_id) => ({
display_id,
file_primary_key: selected_file_id,
date_created: new Date()
}));
await db.files_on_display.bulkPut(new_fods);
await start_task_processing();
}
async function find_file_data_on_active_selected_display(
selected_file_id: string,
selected_display_ids: string[]
): Promise<{
file_on_display: FileOnDisplay;
file: Inode;
short_display_with_file: ShortDisplay;
short_displays_without_file: ShortDisplay[];
} | null> {
const active_selected_displays = await db.displays
.where('id')
.anyOf(selected_display_ids)
.filter((d) => d.status === 'app_online')
.toArray();
const active_selected_display_ids = active_selected_displays.map((d) => d.id);
const fods = await db.files_on_display
.where('file_primary_key')
.equals(selected_file_id)
.filter((e) => active_selected_display_ids.includes(e.display_id))
.toArray();
if (fods.length < 0) return null;
const fod_with_file = fods[0];
const display_ids_with_file = fods.map((f) => f.display_id);
const ip = active_selected_displays.find((e) => e.id === fod_with_file.display_id)?.ip;
if (!ip) return null;
const short_displays_without_file: ShortDisplay[] = selected_display_ids
.filter((d) => !display_ids_with_file.includes(d))
.map((id) => {
const ip = active_selected_displays.find((d) => d.id === id)?.ip;
if (!ip) return null;
return { id, ip };
})
.filter((sd) => !!sd);
const inode = await get_file_by_id(selected_file_id);
if (!inode) return null;
return {
file_on_display: fod_with_file,
file: inode,
short_display_with_file: { id: fod_with_file.display_id, ip },
short_displays_without_file
};
}
function generate_valid_file_name(original_file_name: string, used_file_names: string[]): string {
const regex = /\s\((\d{1,3})\)$/;
let name: string = make_valid_name(original_file_name);
while (used_file_names.includes(name)) {
const last_dot = name.lastIndexOf('.');
let name_without_extension = last_dot > 0 ? name.slice(0, last_dot) : name;
const extension = last_dot > 0 ? name.slice(last_dot) : '';
if (!regex.test(name_without_extension)) {
name_without_extension += ' (1)';
} else {
const match = name_without_extension.match(regex);
const current_number: number = match ? Number(match[1]) : 0;
name_without_extension = name_without_extension.replace(regex, ` (${current_number + 1})`);
}
name = name_without_extension + extension;
}
return name;
}
async function upload(file_primary_key: string, task: FileTransferTask): Promise<void> {
const task_data = task.data;
if (task_data.type !== 'upload' || !task_data.file)
return console.warn('Task cancelled: wrong task type:', task);
await upload_file_via_xhr(file_primary_key, task, task_data.file);
}
export async function sync(file_primary_key: string, task: FileTransferTask) {
if (task.data.type !== 'sync') return console.warn('Task cancelled: wrong task type:', task);
const hasOPFS =
typeof navigator !== 'undefined' &&
'storage' in navigator &&
'getDirectory' in navigator.storage;
if (!hasOPFS) {
return show_general_error(
file_primary_key,
task,
'OPFS (navigator.storage.getDirectory) nicht verfügbar bitte Chromium/Edge/Chrome nutzen.'
);
}
const temp_name = `sync-${get_uuid()}.tmp`;
try {
// 01 - download file to disk (in browser, not for user)
const url = `http://${task.display.ip}:1323/api${get_sanitized_file_url(task.path + task.file_name)}`;
const fetch_source = await fetch(url, { method: 'GET' });
if (!fetch_source.ok || !fetch_source.body)
return show_general_error(file_primary_key, task, `HTTP ${fetch_source.status}`);
const dir = await navigator.storage.getDirectory();
const file_handle = await dir.getFileHandle(temp_name, { create: true });
const writable = await file_handle.createWritable();
const reader = fetch_source.body.getReader();
const start_time = new Date();
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
update_current_loading_data(file_primary_key, value.byteLength, start_time);
await writable.write(value);
}
await writable.close();
finish_loading_data(file_primary_key);
// 02 - send downloaded file to every destination_display
const temp_file = await file_handle.getFile();
for (const current_short_display of task.data.destination_display_data) {
await upload_file_via_xhr(file_primary_key, task, temp_file, current_short_display.display);
}
await dir.removeEntry(temp_name);
} catch (e) {
show_general_error(file_primary_key, task, String(e));
}
}
export async function download_file(selected_file_id: string, selected_display_ids: string[]) {
const file_data = await find_file_data_on_active_selected_display(
selected_file_id,
selected_display_ids
);
if (!file_data || is_folder(file_data.file))
return console.warn('Download cancelled: is folder');
try {
const url = `http://${file_data.short_display_with_file.ip}:1323/api${get_sanitized_file_url(file_data.file.path + file_data.file.name)}`;
const a = document.createElement('a');
a.href = url;
a.download = file_data.file.name; // Hint für den Browser (Server-Header ist trotzdem besser)
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
} catch (e) {
notifications.push(
'error',
'Fehler beim Dateidownload',
`Datei: "${file_data.file.name}", Display-IP: ${file_data.short_display_with_file.ip}\nFehler: ${e}`
);
}
}
async function start_task_processing() {
if (!is_processing) {
is_processing = Object.keys(get(file_transfer_tasks)).length !== 0;
await start_task_loop();
}
}
async function start_task_loop() {
while (Object.keys(get(file_transfer_tasks)).length > 0) {
const tasks = get(file_transfer_tasks);
const current_file_id = Object.keys(tasks)[0];
const current_task = tasks[current_file_id];
if (current_task.data.type === 'upload') {
await upload(current_file_id, current_task);
} else if (current_task.data.type === 'sync') {
await sync(current_file_id, current_task);
}
file_transfer_tasks.update((all_tasks) => {
const next = { ...all_tasks };
delete next[current_file_id];
return next;
});
}
is_processing = false;
}
async function upload_file_via_xhr(
file_primary_key: string,
task: FileTransferTask,
current_file: File,
destination_short_display: ShortDisplay | null = null
) {
const start_time = new Date();
return new Promise<void>((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open(
'POST',
`http://${destination_short_display ? destination_short_display.ip : task.display.ip}:1323/api${get_sanitized_file_url(task.path + task.file_name)}`,
true
);
xhr.setRequestHeader('content-type', 'application/octet-stream');
xhr.upload.onprogress = (e) => {
const apply = async () => {
update_current_loading_data(
file_primary_key,
e.loaded,
start_time,
destination_short_display ? destination_short_display.id : null
);
};
apply();
};
xhr.onerror = async (e: ProgressEvent) => {
await show_general_error(file_primary_key, task, e);
resolve();
};
xhr.onreadystatechange = async () => {
if (xhr.readyState === 4) {
if (xhr.status == 200) {
// set loading_data to 100%
finish_loading_data(
file_primary_key,
destination_short_display ? destination_short_display.id : null
);
// Generate Thumbnail if not done already
setTimeout(async () => {
const inode_element: Inode | undefined = await db.files.get(
JSON.parse(file_primary_key) as [string, string, number, string]
);
if (!!inode_element && inode_element.thumbnail === null) {
await generate_thumbnail(task.display.ip, task.path, inode_element);
}
}, 10);
} else {
await show_general_error(file_primary_key, task, `HTTP ${xhr.status}`);
}
resolve();
}
};
xhr.send(current_file);
});
}
function finish_loading_data(
file_primary_key: string,
destination_display_id: string | null = null
) {
file_transfer_tasks.update((tasks) => {
const task = tasks[file_primary_key];
const current_loading_data = {
percentage: 100,
bytes_per_second: 0,
seconds_until_finish: 0
};
return {
...tasks,
[file_primary_key]: get_updated_task(current_loading_data, destination_display_id, task)
};
});
}
function update_current_loading_data(
file_primary_key: string,
current_bytes: number,
start_time: Date,
destination_display_id: string | null = null
) {
file_transfer_tasks.update((tasks) => {
const task = tasks[file_primary_key];
if (!task) return tasks;
const current_percentage = Math.min(
task.bytes_total > 0 ? Math.round((current_bytes / task.bytes_total) * 100) : 1,
99
);
const prognosed_data = get_prognosed_data(start_time, current_bytes, task.bytes_total);
const current_loading_data: FileLoadingData = {
percentage: current_percentage,
bytes_per_second: prognosed_data.bytes_per_second,
seconds_until_finish: prognosed_data.seconds_until_finish
};
return {
...tasks,
[file_primary_key]: get_updated_task(current_loading_data, destination_display_id, task)
};
});
}
function get_updated_task(current_loading_data: FileLoadingData, destination_display_id: string | null, task: FileTransferTask): FileTransferTask {
if (destination_display_id && task.data.type === 'sync') {
const updatedDestinations = task.data.destination_display_data.map((dd) =>
dd.display.id === destination_display_id ? { ...dd, loading_data: current_loading_data } : dd
);
return {
...task,
data: {
...task.data,
destination_display_data: updatedDestinations
}
};
} else {
return {
...task,
loading_data: current_loading_data
};
}
}
function get_prognosed_data(
start_time: Date,
bytes_done: number,
bytes_total: number
): { bytes_per_second: number; seconds_until_finish: number } {
const diff_seconds = (Date.now() - start_time.getTime()) / 1000;
const bytes_per_second = bytes_done / diff_seconds;
const seconds_until_finish = bytes_total / bytes_per_second;
return { bytes_per_second, seconds_until_finish };
}
async function show_general_error(
file_primary_key: string,
task: FileTransferTask,
error: ProgressEvent | string
) {
const task_data = task.data;
console.error('Error in File-Transfer-Handler:', error, task);
notifications.push(
'error',
`Fehler beim ${task_data.type === 'upload' ? 'Dateiupload' : 'Sychronisieren von Dateien'}`,
`Datei: "${task.file_name}", Display-IP: ${task.display.ip}\nFehler: ${error}`
);
if (task_data.type === 'upload') {
await remove_file_from_display_recusively(task.display.id, file_primary_key);
await remove_all_files_without_display();
} else if (task_data.type === 'sync') {
for (const display_id of task_data.destination_display_data.map((e) => e.display.id)) {
await remove_file_from_display_recusively(display_id, file_primary_key);
}
await remove_all_files_without_display();
}
}
function show_already_in_tasks_error(file: Inode, process: 'upload' | 'sync') {
notifications.push(
'error',
`Datei '${file.name}' konnte nicht ${process === 'upload' ? 'hochgeladen' : 'synchronisiert'} werden`,
`Eine Datei kann nicht in mehreren file_transfer_tasks enthalten sein. Bitte erneut versuchen, wenn die aktuellen Aktionen fertiggestellt wurden`
);
}
+71
View File
@@ -0,0 +1,71 @@
import { screenshot_loop } from './stores/displays';
import { ping_ip } from './api_handler';
import type { Display, DisplayStatus } from './types';
import { update_folder_elements_recursively } from './stores/files';
import { db } from './database';
const update_display_status_interval_seconds = 20;
const update_display_loading_status_interval_seconds = 2;
const loading_display_ids: string[] = [];
export async function on_app_start() {
await db.files.clear();
await db.files_on_display.clear();
await db.displays
.toCollection()
.modify({ status: null, preview: { currently_updating: false, url: null } });
await update_all_display_status(false);
await setInterval(
() => update_all_display_status(false),
update_display_status_interval_seconds * 1000
);
await setInterval(
() => update_all_display_status(true),
update_display_loading_status_interval_seconds * 1000
);
}
async function update_all_display_status(only_loading_displays: boolean) {
const not_loading_displays: Display[] = await db.displays
.filter((d) =>
only_loading_displays
? loading_display_ids.includes(d.id)
: !loading_display_ids.includes(d.id)
)
.toArray();
for (const display of not_loading_displays) {
await update_display_status(display);
}
}
export async function update_display_status(display: Display): Promise<DisplayStatus> {
const new_status = await ping_ip(display.ip);
if (new_status === null && display.status !== null) return null;
if (new_status !== display.status) {
// status change
if (new_status === 'app_offline') {
loading_display_ids.push(display.id);
} else {
remove_display_from_loading_displays(display.id);
if (new_status === 'app_online') {
on_display_start(display);
}
}
display.status = new_status;
await db.displays.put(display); // save
}
return new_status;
}
export function remove_display_from_loading_displays(display_id: string) {
const index = loading_display_ids.indexOf(display_id);
if (index !== -1) {
loading_display_ids.splice(index, 1);
}
}
async function on_display_start(display: Display) {
await update_folder_elements_recursively(display, '/');
screenshot_loop(display.id);
}
+288
View File
@@ -0,0 +1,288 @@
import { get, writable, type Writable } from 'svelte/store';
import type {
Display,
DisplayGroup,
DisplayIdGroup,
DisplayIdObject,
DisplayStatus
} from '../types';
import { is_selected, select, selected_display_ids } from './select';
import { get_uuid, image_content_hash } from '../utils';
import { get_screenshot } from '../api_handler';
import { delete_and_deselect_unique_files_from_display } from './files';
import { db } from '../database';
import { dev } from '$app/environment';
import { remove_display_from_loading_displays } from '../main';
import { preview_settings } from './ui_behavior';
import { liveQuery } from 'dexie';
export const online_displays: Writable<Display[]> = writable<Display[]>([]);
export const online_displays_sub = liveQuery(() =>
db.displays.where('status').equals('app_online').toArray()
).subscribe((value) => {
online_displays.set(value);
});
export const local_displays: Writable<DisplayIdGroup[]> = writable<DisplayIdGroup[]>([]);
export async function is_display_name_taken(name: string): Promise<boolean> {
const exists = await db.displays.where('name').equals(name).first();
return !!exists;
}
export async function add_display(
ip: string,
mac: string | null,
name: string,
status: DisplayStatus
): Promise<Display | null> {
if (await is_display_name_taken(name)) return null;
const new_id = get_uuid();
const group = await db.display_groups.toCollection().first();
let group_id: string;
if (group) {
group_id = group.id;
} else {
group_id = get_uuid();
await db.display_groups.put({ id: group_id, position: 0 });
}
const element_count_in_group = (await db.displays.where('group_id').equals(group_id).toArray())
.length;
const display = {
id: new_id,
ip,
mac,
position: element_count_in_group,
preview: { currently_updating: false, url: null },
group_id: group_id,
name,
status
};
await db.displays.put(display);
return display;
}
export async function edit_display_data(
display_id: string,
ip: string,
mac: string | null,
name: string
): Promise<Display | null> {
let display = await db.displays.get(display_id);
if (!display) return null;
display = {
...display,
ip: ip,
mac: mac,
name,
preview: { currently_updating: false, url: null }
};
await db.displays.put(display); // save
screenshot_loop(display.id);
return display;
}
export async function remove_display(display_id: string) {
select(selected_display_ids, display_id, 'deselect');
await delete_and_deselect_unique_files_from_display(display_id);
const group_id = (await db.displays.get(display_id))?.group_id;
await db.displays.delete(display_id);
if (group_id && (await db.displays.where('group_id').equals(group_id).toArray()).length === 0) {
await db.display_groups.delete(group_id); // delete empty group
}
await remove_display_from_loading_displays(display_id);
}
export async function all_displays_of_group_selected(
display_group_id: string,
current_selected_displays: string[]
): Promise<boolean> {
const displays_of_group: Display[] = await db.displays
.where('group_id')
.equals(display_group_id)
.toArray();
if (displays_of_group.length === 0) return false;
for (const display of displays_of_group) {
if (!is_selected(display.id, current_selected_displays)) {
return false;
}
}
return true;
}
export async function set_select_for_group(display_group_id: string, new_value: boolean) {
const displays_of_group: Display[] = await db.displays
.where('group_id')
.equals(display_group_id)
.toArray();
for (const display of displays_of_group) {
let action: string;
if (new_value === true) {
action = 'select';
} else {
action = 'deselect';
}
select(selected_display_ids, display.id, action as 'toggle' | 'select' | 'deselect');
}
}
export async function get_display_by_id(display_id: string): Promise<Display | null> {
return (await db.displays.get(display_id)) ?? null;
}
export function screenshot_loop(display_id: string) {
setTimeout(async () => {
let settings = get(preview_settings);
if (settings.mode === 'never') return;
const initial_retry_count = settings.retry_count.now;
const retry_seconds = get(preview_settings).retry_seconds.now;
const display = await db.displays.get(display_id);
if (!display || display.preview.currently_updating) return;
display.preview.currently_updating = true;
await db.displays.update(display.id, { preview: display.preview });
let last_hash: number | null = null;
let retry_count = initial_retry_count;
while (retry_count > 0) {
settings = get(preview_settings);
if (settings.mode === 'never') break;
if (settings.mode !== 'always') retry_count -= 1;
const new_blob = await get_screenshot(display.ip);
settings = get(preview_settings);
if (settings.mode === 'never') break;
if (!new_blob) {
display.preview = { currently_updating: false, url: null };
await db.displays.update(display.id, { preview: display.preview });
return;
}
const new_hash = await image_content_hash(new_blob);
if (last_hash !== new_hash) {
if (display.preview.url) {
URL.revokeObjectURL(display.preview.url);
}
last_hash = new_hash;
display.preview.url = URL.createObjectURL(new_blob);
await db.displays.update(display.id, { preview: display.preview });
retry_count = initial_retry_count;
}
await new Promise((resolve) => setTimeout(resolve, retry_seconds * 1000)); // sleep
}
if (settings.mode === 'never') {
await db.displays.update(display.id, { preview: { currently_updating: false, url: null } });
} else {
display.preview.currently_updating = false;
await db.displays.update(display.id, { preview: display.preview });
}
}, 0);
}
export async function run_on_all_selected_displays(
run_function: (display: Display) => void | Promise<void>,
update_screenshot_afterwards: boolean = true,
ignore_offline: boolean = true
) {
const maybe_displays: (Display | null)[] = await Promise.all(
// fails when only a single promis fails
get(selected_display_ids).map(async (id) => await get_display_by_id(id))
);
const displays: Display[] = maybe_displays.filter((d): d is Display => d !== null);
Promise.all(
displays.map(async (display) => {
if (!display || (ignore_offline && display.status === 'host_offline')) return;
await run_function(display);
if (update_screenshot_afterwards) {
screenshot_loop(display.id);
}
})
);
}
export async function get_display_groups(): Promise<DisplayGroup[]> {
return await db.display_groups.orderBy('position').toArray();
}
export async function update_local_displays() {
const display_groups = await db.display_groups.orderBy('position').toArray();
const displays = await db.displays.orderBy('position').toArray();
const out: DisplayIdGroup[] = [];
for (const group of display_groups) {
out.push({
id: group.id,
displays: (await displays).filter((d) => d.group_id === group.id).map((d) => ({ id: d.id }))
});
}
return out;
}
export async function update_db_displays() {
local_displays.update((groups) => groups.filter((g) => g.displays.length !== 0));
const filtered_local_display_groups = get(local_displays);
const db_display_group_ids = (await db.display_groups.toArray()).map((group) => group.id);
const local_display_group_ids = filtered_local_display_groups.map((group) => group.id);
const display_group_ids_to_delete = db_display_group_ids.filter(
(group) => !local_display_group_ids.includes(group)
);
await db.display_groups.where('id').anyOf(display_group_ids_to_delete).delete();
for (let i = 0; i < filtered_local_display_groups.length; i++) {
const group = filtered_local_display_groups[i];
if (db_display_group_ids.includes(group.id)) {
await db.display_groups.update(group.id, { position: i });
} else {
await db.display_groups.put({
id: group.id,
position: i
});
}
for (let j = 0; j < group.displays.length; j++) {
const display_id = group.displays[j].id;
await db.displays.update(display_id, { position: j, group_id: group.id });
}
}
}
export function set_new_display_order(display_id_group_id: string, new_data: DisplayIdObject[]) {
local_displays.update((local_displays: DisplayIdGroup[]) => {
for (const display_id_group of local_displays) {
if (display_id_group.id === display_id_group_id) {
display_id_group.displays = new_data;
}
}
return local_displays;
});
}
export function no_active_display_selected(
selected_display_ids: string[],
online_displays: Display[]
) {
const online_and_selected_displays = online_displays.filter((d) =>
selected_display_ids.includes(d.id)
);
return online_and_selected_displays.length === 0;
}
if (dev) {
setTimeout(add_testing_displays, 0);
async function add_testing_displays() {
await add_display('127.0.0.1', '00:1A:2B:3C:4D:5E', 'PC', 'host_offline');
// await add_display("192.168.178.111", "D4:81:D7:C0:DF:3C", "Laptop", "host_offline");
}
}
+443
View File
@@ -0,0 +1,443 @@
import { get, writable, type Writable } from 'svelte/store';
import {
get_file_primary_key,
is_folder,
type Display,
type FileOnDisplay,
type Inode,
type TreeElement
} from '../types';
import { get_display_by_id } from './displays';
import { is_selected, select, selected_display_ids, selected_file_ids } from './select';
import { create_path, get_file_data, get_file_tree_data } from '../api_handler';
import { deactivate_old_thumbnail_urls, generate_thumbnail } from './thumbnails';
import { db } from '../database';
import { file_transfer_tasks } from '../file_transfer_handler';
export const current_file_path: Writable<string> = writable<string>('/');
export async function change_file_path(new_path: string) {
current_file_path.update(() => {
return new_path;
});
selected_file_ids.update(() => {
return [];
});
deactivate_old_thumbnail_urls();
const displays = await db.displays.toArray();
for (const display of displays) {
const changed_paths = await get_changed_directory_paths(display, new_path);
if (!changed_paths) continue;
console.debug('Update file system from', display.name, ':', changed_paths);
for (const path of changed_paths) {
await update_folder_elements_recursively(display, path);
}
}
}
export async function delete_and_deselect_unique_files_from_display(display_id: string) {
const files_on_display = await db.files_on_display
.where('display_id')
.equals(display_id)
.toArray();
for (const file of files_on_display) {
await remove_file_from_display_recusively(display_id, file.file_primary_key);
}
await remove_all_files_without_display();
}
export async function remove_file_from_display_recusively(
display_id: string,
file_primary_key: string
) {
const found = await db.files_on_display.get([display_id, file_primary_key]);
if (!found) return;
select(selected_file_ids, file_primary_key, 'deselect');
await db.files_on_display.delete([display_id, file_primary_key]);
const inode_element = await get_file_by_id(found.file_primary_key);
if (!inode_element) return;
if (is_folder(inode_element)) {
const path_inside_folder = inode_element.path + inode_element.name + `/`;
const primary_file_keys_in_folder = (
await db.files.where('path').equals(path_inside_folder).toArray()
).map((e) => get_file_primary_key(e));
const primary_file_keys_in_folder_on_display = (
await db.files_on_display
.where('file_primary_key')
.anyOf(primary_file_keys_in_folder)
.filter((file) => file.display_id === display_id)
.toArray()
).map((e) => e.file_primary_key);
for (const file_key of primary_file_keys_in_folder_on_display) {
await remove_file_from_display_recusively(display_id, file_key);
}
}
}
export async function remove_all_files_without_display() {
const existing_file_id_strings: string[] = (await db.files_on_display
.orderBy('file_primary_key')
.uniqueKeys()) as string[];
const existing_file_id_objects: [string, string, number, string][] = existing_file_id_strings.map(
(e) => JSON.parse(e) as [string, string, number, string]
);
await db.files.where('[path+name+size+type]').noneOf(existing_file_id_objects).delete();
}
export async function update_current_folder_on_selected_displays() {
selected_file_ids.update(() => {
return [];
});
const current_path = get(current_file_path);
for (const display of await db.displays.where('id').anyOf(get(selected_display_ids)).toArray()) {
await update_folder_elements_recursively(display, current_path);
}
}
export async function get_missing_colliding_display_ids(
file: Inode,
selected_display_ids: string[]
): Promise<{ missing: string[]; colliding: string[] }> {
const missing: string[] = await get_display_ids_where_file_is_missing(file, selected_display_ids);
const colliding: string[] = [];
const colliding_files = await db.files
.where('[path+name]')
.equals([file.path, file.name])
.filter((e) => e.size !== file.size || e.type !== file.type)
.toArray();
for (const colliding_file of colliding_files) {
colliding.push(
...(await get_display_ids_where_file_is_missing(colliding_file, selected_display_ids))
);
}
return { missing, colliding };
}
async function get_display_ids_where_file_is_missing(
file: Inode,
selected_display_ids: string[]
): Promise<string[]> {
const file_primary_key = get_file_primary_key(file);
const files_on_selected_displays = await db.files_on_display
.where('file_primary_key')
.equals(file_primary_key)
.filter((e) => selected_display_ids.includes(e.display_id))
.toArray();
return selected_display_ids.filter(
(id) => !files_on_selected_displays.some((item) => item.display_id === id)
);
}
export async function get_displays_where_path_not_exists(
path: string,
selected_display_ids: string[]
): Promise<Display[]> {
if (path === '/') return await db.displays.where('id').anyOf(selected_display_ids).toArray();
const last_path_part =
path
.slice(0, path.length - 1)
.split('/')
.at(-1) ?? '';
const path_without_last_part = path.slice(0, path.length - (last_path_part.length + 1));
const folders_of_current_path = await db.files
.where('name')
.equals(last_path_part)
.filter((inode) => inode.path === path_without_last_part && is_folder(inode))
.first();
if (!folders_of_current_path) return [];
const folder_primary_key = get_file_primary_key(folders_of_current_path);
const display_ids_with_folder = (
await db.files_on_display.where('file_primary_key').equals(folder_primary_key).toArray()
).map((fod) => fod.display_id);
const display_ids = selected_display_ids.filter((display_id) => {
return !display_ids_with_folder.includes(display_id);
});
return (await db.displays.bulkGet(display_ids)).filter((e) => e !== undefined);
}
async function get_changed_directory_paths(
display: Display,
file_path: string
): Promise<string[] | null> {
const current_folder = await get_file_tree_data(display.ip, file_path);
const directory_strings = await get_recursive_changed_directory_paths(
display,
file_path,
current_folder
);
if (directory_strings.size === 0) return null;
const directory_strings_array = [...directory_strings];
return directory_strings_array.filter(
(e) => !directory_strings_array.some((f) => f !== e && f.startsWith(e))
);
}
async function get_recursive_changed_directory_paths(
display: Display,
current_file_path: string,
current_folder_elements: TreeElement[] | null
): Promise<Set<string>> {
const files_folder: Inode[] = await db.files.where('path').equals(current_file_path).toArray();
if (
(!files_folder || files_folder.length === 0) &&
(!current_folder_elements || current_folder_elements.length === 0)
) {
return new Set([]); // no data -> no update needed
} else if (
!files_folder ||
!current_folder_elements ||
current_folder_elements.length !== files_folder.length
) {
return new Set([current_file_path]); // existing data does not match new data -> update
}
const has_changed: Set<string> = new Set();
for (const tree_folder_element of current_folder_elements) {
const folder_element = files_folder.find((e) => e.name === tree_folder_element.name);
if (
!folder_element ||
(tree_folder_element.type !== 'directory' && folder_element.size !== tree_folder_element.size)
) {
return new Set([current_file_path]);
}
if (tree_folder_element.type === 'directory' && tree_folder_element.contents) {
const new_file_path = current_file_path + tree_folder_element.name + '/';
for (const string of await get_recursive_changed_directory_paths(
display,
new_file_path,
tree_folder_element.contents
)) {
has_changed.add(string);
}
}
}
return has_changed;
}
export async function update_folder_elements_recursively(
display: Display,
file_path: string = '/'
): Promise<void> {
const new_folder_elements = await get_file_data(display.ip, file_path);
if (!new_folder_elements) return;
const loading_file_ids = Object.keys(get(file_transfer_tasks));
const loading_file_keys_without_size = (
await db.files.bulkGet(
loading_file_ids.map((string_id) => JSON.parse(string_id) as [string, string, number, string])
)
)
.filter((f) => !!f)
.map((f) => JSON.stringify([f.path, f.name, f.type]));
// Filter new files, which aren't currently uploading
const existing_files_on_display_in_path: FileOnDisplay[] = await db.files_on_display
.where('display_id')
.equals(display.id)
.filter((f) => !loading_file_ids.includes(f.file_primary_key))
.toArray();
const existing_file_keys_on_display_in_path: [string, string, number, string][] =
existing_files_on_display_in_path.map(
(e) => JSON.parse(e.file_primary_key) as [string, string, number, string]
);
const existing_files: Inode[] = await db.files
.where('[path+name+size+type]')
.anyOf(existing_file_keys_on_display_in_path)
.filter((e) => e.path === file_path)
.toArray();
const diff = get_folder_elements_difference(existing_files, new_folder_elements);
// Filter new files, which aren't currently uploading -> don't compare size
diff.new = diff.new.filter(
(e) =>
!loading_file_keys_without_size.includes(
JSON.stringify([e.folder_element.path, e.folder_element.name, e.folder_element.type])
)
);
if (diff.new.length > 0) {
// Add new Folder-Elements
for (const new_element of diff.new) {
await db.files.put(new_element.folder_element);
const file_on_display: FileOnDisplay = {
display_id: display.id,
file_primary_key: get_file_primary_key(new_element.folder_element),
date_created: new_element.date_created
};
await db.files_on_display.put(file_on_display);
if (is_folder(new_element.folder_element)) {
await update_folder_elements_recursively(
display,
file_path + new_element.folder_element.name + '/'
);
}
}
// Generate Thumbnails:
setTimeout(async () => {
for (const new_element of diff.new) {
await generate_thumbnail(display.ip, file_path, new_element.folder_element);
}
}, 0);
}
if (diff.deleted.length > 0) {
// Remove old Folder-Elements
for (const old_element of diff.deleted) {
remove_file_from_display_recusively(display.id, get_file_primary_key(old_element));
}
await remove_all_files_without_display();
}
}
function get_folder_elements_difference(
old_elements: Inode[],
new_elements: { folder_element: Inode; date_created: Date }[]
): { deleted: Inode[]; new: { folder_element: Inode; date_created: Date }[] } {
const old_keys = new Set(old_elements.map((e) => get_file_primary_key(e)));
const new_keys = new Set(new_elements.map((e) => get_file_primary_key(e.folder_element)));
const only_in_old = old_elements.filter((e) => !new_keys.has(get_file_primary_key(e)));
const only_in_new = new_elements.filter(
(e) => !old_keys.has(get_file_primary_key(e.folder_element))
);
return { deleted: only_in_old, new: only_in_new };
}
export async function get_folder_elements(
file_path: string,
selected_display_ids: string[]
): Promise<Inode[]> {
const existing_file_keys_on_selected_displays: [string, string, number, string][] = (
await db.files_on_display.where('display_id').anyOf(selected_display_ids).toArray()
).map((e) => JSON.parse(e.file_primary_key) as [string, string, number, string]);
const existing_files_on_selected_displays_in_path: Inode[] = await db.files
.where('[path+name+size+type]')
.anyOf(existing_file_keys_on_selected_displays)
.filter((e) => e.path === file_path)
.toArray();
return sort_files(existing_files_on_selected_displays_in_path);
}
function sort_files(files: Inode[]) {
files.sort((a, b) => {
const isDirA = is_folder(a);
const isDirB = is_folder(b);
// Ordner zuerst
if (isDirA && !isDirB) return -1;
if (!isDirA && isDirB) return 1;
// Danach alphabetisch nach name (case-insensitive)
const nameCompare = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
if (nameCompare !== 0) return nameCompare;
return -1;
});
return files;
}
export async function get_file_by_id(
file_primary_key: string,
only_from_selected_displays: boolean = false
): Promise<Inode | null> {
const file = (await db.files.get(JSON.parse(file_primary_key))) ?? null;
if (!file || !only_from_selected_displays) {
return file;
} else {
const exist_on_selected_display = !!(await db.files_on_display
.where('file_primary_key')
.equals(file_primary_key)
.filter((e) => get(selected_display_ids).includes(e.display_id))
.first());
return exist_on_selected_display ? file : null;
}
}
export async function run_for_selected_files_on_selected_displays(
action: (ip: string, file_names: string[]) => Promise<void>
): Promise<void> {
for (const display_id of get(selected_display_ids)) {
const file_key_strings_on_display: string[] = (
await db.files_on_display.where('display_id').equals(display_id).toArray()
).map((e) => e.file_primary_key);
const selected_file_keys_on_display: [string, string, number, string][] =
file_key_strings_on_display
.filter((primary_key_string) => is_selected(primary_key_string, get(selected_file_ids)))
.map(
(primary_key_string) => JSON.parse(primary_key_string) as [string, string, number, string]
);
if (selected_file_keys_on_display.length === 0) continue;
const selected_file_names_on_display: string[] = (
await db.files.where('[path+name+size+type]').anyOf(selected_file_keys_on_display).toArray()
).map((e) => e.name);
const display = await get_display_by_id(display_id);
if (!display) continue;
await action(display.ip, selected_file_names_on_display);
}
}
export async function create_path_on_all_selected_displays(
path: string,
selected_display_ids: string[]
) {
const displays_without_path = await get_displays_where_path_not_exists(
path,
selected_display_ids
);
if (displays_without_path.length === 0) return;
for (const display of displays_without_path) {
await create_path(display.ip, path);
}
setTimeout(async () => {
for (const display of displays_without_path) {
const changed_paths = await get_changed_directory_paths(display, '/');
if (!changed_paths) continue;
console.debug('Update file system from', display.name, ':', changed_paths);
for (const path of changed_paths) {
await update_folder_elements_recursively(display, path);
}
}
}, 0);
}
export async function get_date_mapping(file_primary_key: string): Promise<Record<string, Date>> {
const same_file_on_displays: FileOnDisplay[] = await db.files_on_display
.where('file_primary_key')
.equals(file_primary_key)
.toArray();
const displays_with_that_file: Display[] = await db.displays
.where('id')
.anyOf(same_file_on_displays.map((e) => e.display_id))
.toArray();
const out: Record<string, Date> = {};
for (const current_file_on_display of same_file_on_displays) {
const current_name = displays_with_that_file.find(
(e) => e.id === current_file_on_display.display_id
)?.name;
if (!current_name) continue;
const current_date = current_file_on_display.date_created;
out[current_name] = current_date;
}
return out;
}
@@ -0,0 +1,28 @@
import { writable } from "svelte/store";
export type Notification = {
id: number;
title: string;
message: string;
duration: number;
className: string;
type?: "error" | "success" | "info";
};
function createNotifications() {
const { subscribe, update } = writable<Notification[]>([]);
function push(type: "error" | "success" | "info", title: string, message: string = "", className: string = "") {
const id = Date.now();
const duration = type === "error" ? 16000 : 4000;
update((n) => [...n, { id, title, message, duration, className, type }]);
setTimeout(() => {
update((n) => n.filter((x) => x.id !== id));
}, duration);
}
const remove = (id: number) => update(n => n.filter(x => x.id !== id));
return { subscribe, push, remove };
}
export const notifications = createNotifications();
+26
View File
@@ -0,0 +1,26 @@
import { writable, type Writable } from 'svelte/store';
export const selected_file_ids: Writable<string[]> = writable<string[]>([]); // JSON.stringify([string, string, number, string])
export const selected_display_ids: Writable<string[]> = writable<string[]>([]);
export function select(
selected_ids: Writable<string[]>,
id: string,
action: 'toggle' | 'select' | 'deselect'
) {
selected_ids.update((all_ids: string[]) => {
if (all_ids.includes(id)) {
const index = all_ids.indexOf(id);
if (index > -1 && action !== 'select') {
all_ids.splice(index, 1);
}
} else if (action !== 'deselect') {
all_ids.push(id);
}
return all_ids;
});
}
export function is_selected(id: string, selected_ids: string[]): boolean {
return selected_ids.includes(id);
}
+45
View File
@@ -0,0 +1,45 @@
import { get, writable, type Writable } from 'svelte/store';
import { get_thumbnail_blob } from '../api_handler';
import { type Inode } from '../types';
import { db } from '../database';
import { get_file_type } from '../utils';
import { get_file_by_id } from './files';
export const active_thumbnail_urls: Writable<string[]> = writable<string[]>([]);
export async function generate_thumbnail(
display_ip: string,
path: string,
folder_element: Inode
): Promise<void> {
const supported_file_type = get_file_type(folder_element);
if (!supported_file_type) return;
const thumbnail_blob = await get_thumbnail_blob(display_ip, path + folder_element.name);
if (!thumbnail_blob) return;
folder_element.thumbnail = thumbnail_blob;
await db.files.put(folder_element); // save
}
export async function get_thumbnail_url(file_primary_key: string): Promise<string | null> {
const file: Inode | null = await get_file_by_id(file_primary_key);
if (!file || !file.thumbnail) return null;
const new_url = URL.createObjectURL(file.thumbnail);
active_thumbnail_urls.update((current: string[]) => {
current.push(new_url);
return current;
});
return new_url;
}
export function deactivate_old_thumbnail_urls() {
const current_urls = get(active_thumbnail_urls);
for (const url of current_urls) {
URL.revokeObjectURL(url);
}
active_thumbnail_urls.update(() => {
return [];
});
}
@@ -0,0 +1,97 @@
import { writable, type Writable } from 'svelte/store';
import type { NumberSetting } from '../types';
import { dev } from '$app/environment';
export const dnd_flip_duration_ms = 300;
const heights_options: Record<string, { min: number; max: number; step: number }> = {
display: {
min: 15,
max: 40,
step: 5
},
file: {
min: 10,
max: 20,
step: 5
}
};
export const current_height: Writable<Record<string, number>> = writable<Record<string, number>>({
display: 25,
file: 10
});
export const is_group_drag: Writable<boolean> = writable<boolean>(false);
export const is_display_drag: Writable<boolean> = writable<boolean>(false);
export const preview_settings: Writable<{
mode: 'never' | 'normal' | 'always';
retry_seconds: NumberSetting;
retry_count: NumberSetting;
}> = writable<{
mode: 'never' | 'normal' | 'always';
retry_seconds: NumberSetting;
retry_count: NumberSetting;
}>({
mode: dev ? 'never' : 'normal',
retry_seconds: {
max: 10,
min: 0.5,
now: 2,
step: 0.5
},
retry_count: {
max: 10,
min: 1,
now: 5,
step: 1
}
});
export const pinned_display_id: Writable<string | null> = writable<string | null>(null);
export function change_height(key: 'display' | 'file', factor: number) {
current_height.update((height) => {
height[key] = next_height_step_size(key, height, factor) || height[key];
return height;
});
}
export function next_height_step_size(
key: 'display' | 'file',
current_height_array: Record<string, number>,
factor: number
): number {
const new_size = current_height_array[key] + factor * heights_options[key].step;
if (new_size > heights_options[key].max || new_size < heights_options[key].min) {
return 0;
} else {
return new_size;
}
}
export function get_selectable_color_classes(
selected: boolean,
returning_classes: { bg?: boolean; hover?: boolean; active?: boolean; text?: boolean } = {},
base_bg_distance: number = 0,
shifted_distance: number = 0
) {
let base_bg = selected ? 'bg-stone-400' : 'bg-stone-600';
const base_text = selected ? 'text-stone-950' : 'text-stone-200';
base_bg = get_shifted_color(base_bg, base_bg_distance);
const { bg = false, hover = false, active = false, text = false } = returning_classes;
const out: string[] = [];
if (bg) out.push(base_bg);
if (hover) out.push('hover:' + get_shifted_color(base_bg, 100 + shifted_distance));
if (active) out.push('active:' + get_shifted_color(base_bg, 150 + shifted_distance));
if (text) out.push(base_text);
return out.join(' ');
}
export function get_shifted_color(base_color: string, distance: number): string {
return base_color.replace(/(\d+)(?=(?:\/\d+)?$)/, (m: string) => String(+Number(m) - distance));
}
+154
View File
@@ -0,0 +1,154 @@
import { FileBox, FileImage, FileText, FileVideoCamera, ImagePlay, type X } from 'lucide-svelte';
import type { Snippet } from 'svelte';
export type RequestResponse = {
ok: boolean;
http_code?: number;
blob?: Blob;
json?: Record<string, unknown>;
};
export type ShellCommandResponse = {
stdout: string;
stderr: string;
exitCode: number;
};
export type SupportedFileType = {
display_name: string;
mime_type: string;
};
export const supported_file_type_icon: Record<string, typeof X> = {
MP4: FileVideoCamera,
JPG: FileImage,
PNG: FileImage,
GIF: ImagePlay,
PPTX: FileBox,
ODP: FileBox,
PDF: FileText
};
export type FileTransferTask = {
data: FileTransferTaskData;
display: ShortDisplay;
path: string;
file_name: string;
loading_data: FileLoadingData;
bytes_total: number; // if type === 'sync' -> bytes_total = file_size * 2 (1x download + 1x upload)
};
export type FileTransferTaskData =
| {
type: 'upload';
file: File;
}
| {
type: 'sync';
destination_display_data: {
display: ShortDisplay;
loading_data: FileLoadingData;
}[];
};
export type FileLoadingData = {
percentage: number;
bytes_per_second: number;
seconds_until_finish: number;
};
export type ShortDisplay = {
id: string;
ip: string;
};
export type FileOnDisplay = {
display_id: string;
file_primary_key: string; // JSON.stringify([string, string, number, string])
date_created: Date;
};
export type Inode = {
path: string;
name: string;
size: number;
type: string;
thumbnail: Blob | null;
};
export function get_file_primary_key(file: Inode): string {
return JSON.stringify([file.path, file.name, file.size, file.type]);
}
export type TreeElement = {
contents?: TreeElement[];
type: 'file' | 'directory';
name: string;
size: number;
};
export type Display = {
id: string;
ip: string;
mac: string | null;
position: number;
preview: PreviewObject;
group_id: string;
name: string;
status: DisplayStatus;
};
export type DisplayGroup = {
id: string;
position: number;
};
export type DisplayIdGroup = {
id: string;
displays: DisplayIdObject[];
};
export type DisplayIdObject = {
id: string;
};
export type PreviewObject = {
currently_updating: boolean;
url: string | null;
};
export type MenuOption = {
icon?: typeof X;
name: string;
class?: string;
on_select?: () => void | Promise<void>;
disabled?: boolean;
};
export type PopupContent = {
open: boolean;
snippet: Snippet<[any]> | Snippet<[]> | Snippet | null | any;
snippet_arg?: any;
title?: string;
title_class?: string;
title_icon?: typeof X | null;
window_class?: string;
};
export type NumberSetting = {
max: number;
min: number;
now: number;
step: number;
};
export type DisplayStatus = 'host_offline' | 'app_offline' | 'app_online' | null;
export function to_display_status(value: string): DisplayStatus {
return ['host_offline', 'app_offline', 'app_online'].includes(value)
? (value as DisplayStatus)
: null;
}
export function is_folder(inode: Inode) {
return inode.type === 'inode/directory';
}
+115
View File
@@ -0,0 +1,115 @@
import type { DisplayStatus, Inode, SupportedFileType } from './types';
import supported_file_types_json from './../../../../../shared/supported_file_types.json';
const supported_file_types: Record<string, SupportedFileType> = supported_file_types_json as Record<
string,
SupportedFileType
>;
const invalid_first_regex = /^[^a-zA-ZäöüßÄÖÜ0-9_]/u;
const invalid_rest_regex = /[^a-zA-ZäöüßÄÖÜ0-9 _\-()$${}.,+$!=/]/gu;
export function is_valid_name(input: string): boolean {
return !invalid_rest_regex.test(input) && first_letter_is_valid(input);
}
export function first_letter_is_valid(input: string): boolean {
if (input.length === 0) return false;
return !invalid_first_regex.test(input);
}
export function make_valid_name(input: string): string {
const s = input.normalize('NFC');
if (s.length === 0) return 'Datei';
let out = s.replace(invalid_rest_regex, '_');
out = out.replace(invalid_first_regex, '_');
return out;
}
export function get_file_type(file: Inode): SupportedFileType | null {
for (const key of Object.keys(supported_file_types)) {
if (file.type === supported_file_types[key].mime_type) {
return supported_file_types[key];
}
}
// Fallback:
const extension = file.name.split('.').pop();
if (extension) {
if (Object.keys(supported_file_types).includes('.' + extension)) {
return supported_file_types['.' + extension];
}
}
return null;
}
export function get_accepted_file_type_string(): string {
return Object.values(supported_file_types)
.map((e) => e.mime_type)
.join(',');
}
export function get_uuid(): string {
return crypto.randomUUID();
}
export function get_file_size_display_string(size: number, toFixed: number | null = null): string {
if (size < 0)
return toFixed === null ? '...' : 'Größe wird aktuell berechnet';
if (size === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(size) / Math.log(k));
const value = size / Math.pow(k, i);
const size_string = `${value.toFixed(toFixed !== null ? toFixed : Math.max(0, 2 - Math.floor(Math.log10(value))))} ${sizes[i]}`;
return size_string.replace('.', ',');
}
export async function image_content_hash(blob: Blob, size = 32): Promise<number> {
// Blob → ImageBitmap (GPU-dekodiert, superschnell)
const bitmap = await createImageBitmap(blob);
// OffscreenCanvas ist sehr schnell (kein Layout nötig)
const canvas = new OffscreenCanvas(size, size);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0, size, size);
// Pixel-Daten holen
const { data } = ctx.getImageData(0, 0, size, size);
// Einfacher, schneller Integer-Hash (FNV-1a)
let hash = 2166136261;
for (let i = 0; i < data.length; i++) {
hash ^= data[i];
hash = Math.imul(hash, 16777619);
}
bitmap.close(); // GPU-Ressourcen freigeben
return hash >>> 0; // unsigned int
}
export function display_status_to_info(status: DisplayStatus): string {
switch (status) {
case 'app_online':
return 'Online';
case 'app_offline':
return 'Lädt';
case 'host_offline':
return 'Offline';
case null:
return '???';
}
}
export function get_sanitized_file_url(file_path: string, is_preview = false) {
const pathSegments = file_path
.split('/')
.filter(Boolean)
.map((seg) => encodeURIComponent(seg));
return `/file/${is_preview ? 'preview/' : ''}${[...pathSegments].join('/')}`;
}
@@ -1,7 +1,29 @@
<script lang="ts">
import '../app.css';
import Notification from './Notification.svelte';
import SplashScreen from './../../../../shared/splash_screen.html?raw';
import version from './../../../../shared/version.txt?raw';
import { dev } from '$app/environment';
let { children } = $props();
let versionSplashScreen = SplashScreen.replaceAll('%%APP-VERSION%%', version.trim());
</script>
<svelte:head>
<title>PLG MuDiCS Control</title>
</svelte:head>
{#if !dev}
{@html versionSplashScreen}
{/if}
{@render children()}
<Notification />
<style>
:root {
--splash-fade-out-delay: 3.5s !important;
}
</style>
+1
View File
@@ -1 +1,2 @@
export const prerender = true;
export const ssr = false;
+242 -53
View File
@@ -1,22 +1,42 @@
<script lang="ts">
import { Monitor, Plus, Radio, Settings, Trash2, X } from 'lucide-svelte';
import Button from '../components/Button.svelte';
import FileView from '../components/FileView.svelte';
import ControlView from '../components/ControlView.svelte';
import DisplayView from '../components/DisplayView.svelte';
import SplashScreen from './../../../../shared/splash_screen.html?raw';
import PopUp from '../components/PopUp.svelte';
import type { PopupContent } from '../ts/types';
import TextInput from '../components/TextInput.svelte';
import {
Monitor,
Plus,
Radio,
Settings,
Trash2,
Menu,
ChevronDown,
icons,
SquareCheckBig,
Square,
X,
Info
} from 'lucide-svelte';
import Button from '$lib/components/Button.svelte';
import FileView from './FileView.svelte';
import ControlView from './ControlView.svelte';
import DisplayView from './DisplayView.svelte';
import PopUp from '$lib/components/PopUp.svelte';
import { type Display, type PopupContent } from '$lib/ts/types';
import TextInput from '$lib/components/TextInput.svelte';
import {
add_display,
displays,
edit_display_data,
get_display_by_id,
is_display_name_taken,
remove_display
} from '../ts/stores/displays';
import { text } from '@sveltejs/kit';
remove_display,
screenshot_loop
} from '$lib/ts/stores/displays';
import { notifications } from '$lib/ts/stores/notification';
import { ping_ip } from '$lib/ts/api_handler';
import { onMount } from 'svelte';
import { on_app_start, update_display_status } from '$lib/ts/main';
import { display_status_to_info } from '$lib/ts/utils';
import HighlightedText from '$lib/components/HighlightedText.svelte';
import { preview_settings } from '$lib/ts/stores/ui_behavior';
import NumberSettingInput from '$lib/components/NumberSettingInput.svelte';
import { db } from '$lib/ts/database';
const ip_regex =
/^(?:(?:10|127)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)|192\.168\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)|172\.(?:1[6-9]|2\d|3[0-1])\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d))$/;
@@ -26,8 +46,9 @@
open: false,
snippet: null,
title: '',
closable: true
title_class: '!text-xl',
});
let remove_display_name = $state('');
const text_inputs_valid_null_values = {
name: { valid: false, value: '' },
@@ -45,16 +66,46 @@
return true;
}
function finalize_add_edit_display(existing_display_id: string|null) {
async function finalize_add_edit_display(existing_display_id: string | null) {
popup_close_function();
const ip = text_inputs_valid.ip.value;
const mac = text_inputs_valid.mac.value === '' ? null : text_inputs_valid.mac.value;
const name = text_inputs_valid.name.value;
let display: Display | null = null;
if (!!existing_display_id) {
edit_display_data(existing_display_id, ip, mac, name);
display = await edit_display_data(existing_display_id, ip, mac, name);
} else {
add_display(ip, mac, name, 'Online');
const status = await ping_ip(text_inputs_valid.ip.value);
display = await add_display(ip, mac, name, status);
}
if (!!display) {
await update_display_status(display);
}
}
function get_display_preview_mode(mode: 'never' | 'normal' | 'always') {
switch (mode) {
case 'never':
return 'Nie';
case 'normal':
return 'Normal';
case 'always':
return 'Dauerhaft';
}
}
async function change_preview_mode(mode: 'never' | 'normal' | 'always') {
$preview_settings.mode = mode;
if (mode === 'never') {
await db.displays
.toCollection()
.modify({ preview: { currently_updating: false, url: null } });
} else {
const display_ids = (await db.displays.toArray()).map((d) => d.id);
for (const display_id of display_ids) {
screenshot_loop(display_id);
}
}
popup_close_function();
}
function popup_close_function() {
@@ -65,27 +116,39 @@
text_inputs_valid = text_inputs_valid_null_values;
popup_content = {
open: true,
snippet: add_new_display_popup,
title: 'Neuen Bildschirm hinzufügen',
snippet: display_popup,
title: 'Neuen Bildschirm Hinzufügen',
title_icon: Monitor,
closable: true
title_class: '!text-xl',
window_class: 'w-3xl',
};
};
const show_remove_display_popup = (display_id: string) => {
const show_settings_popup = () => {
popup_content = {
open: true,
snippet: settings_popup,
title: 'Einstellungen',
title_icon: Settings,
title_class: '!text-xl',
window_class: 'w-3xl',
};
};
const show_remove_display_popup = async (display_id: string) => {
remove_display_name = (await get_display_by_id(display_id))?.name || '?';
popup_content = {
open: true,
snippet: remove_display_popup,
snippet_arg: display_id,
title: 'Bildschirm wirklich löschen?',
title_class: 'text-red-400',
title_class: 'text-red-400 !text-xl',
title_icon: Trash2,
closable: true
};
};
const show_edit_display_popup = (display_id: string) => {
const display = get_display_by_id(display_id, $displays);
const show_edit_display_popup = async (display_id: string) => {
const display = await get_display_by_id(display_id);
if (!display) return;
// insert existing values in text_inputs_valid
for (const key of Object.keys(text_inputs_valid) as (keyof typeof text_inputs_valid)[]) {
@@ -94,50 +157,107 @@
}
popup_content = {
open: true,
snippet: add_new_display_popup,
snippet: display_popup,
snippet_arg: display_id,
title: 'Bildschirm bearbeiten',
title_icon: Monitor,
closable: true
title_class: '!text-xl',
};
};
onMount(on_app_start);
const show_about_popup = () => {
popup_content = {
open: true,
snippet: about_popup,
title: 'Über PLG MuDiCS',
title_icon: Info,
title_class: '!text-xl',
};
};
</script>
{#snippet about_popup(_: string)}
<div class="px-2">
<h3 class="text-lg font-bold mt-4">Entwickler</h3>
<p>
<a target="_blank" class="link" href="https://github.com/programmer-44">E44</a>
<a target="_blank" class="link" href="https://codeberg.org/2mal3">2mal3</a>,
</p>
<h3 class="text-lg font-bold mt-4">Lizenz</h3>
<a
target="_blank"
class="link"
href="https://github.com/PLG-Development/PLG-MuDiCS/blob/main/LICENSE.txt"
>
GNU Affero General Public License v3 (AGPL-3.0)
</a>
<h3 class="text-lg font-bold mt-4">Verwendete Bibliotheken</h3>
<ul class="list-disc list-inside">
<li><a target="_blank" href="https://svelte.dev/" class="link">Svelte & SvelteKit</a></li>
<li><a target="_blank" href="https://tailwindcss.com/" class="link">TailwindCSS</a></li>
<li><a target="_blank" href="https://lucide.dev/" class="link">Lucide Icons</a></li>
<li><a target="_blank" href="https://tiptap.dev/" class="link">Tiptap</a></li>
<li><a target="_blank" href="https://dexie.org/" class="link">Dexie.js</a></li>
<li>
<a target="_blank" href="https://github.com/orefalo/svelte-splitpanes" class="link"
>svelte-splitpanes</a
>
</li>
<li>
<a target="_blank" href="https://github.com/thisux/sveltednd" class="link"
>@thisux/sveltednd</a
>
</li>
<li><a target="_blank" href="https://echo.labstack.com/" class="link">Echo</a></li>
<li><a target="_blank" href="https://github.com/mdlayher/wol" class="link">wol</a></li>
</ul>
</div>
<div class="flex justify-end pt-2">
<Button click_function={popup_close_function} className="px-4">Schließen</Button>
</div>
{/snippet}
{#snippet remove_display_popup(display_id: string)}
<div class="max-w-prose px-2">
Soll der Bildschirm "{get_display_by_id(display_id, $displays)?.name || '?'}" wirklich gelöscht
werden? Dadurch wird es von diesem Controller nicht mehr erreichbar. Die Installation auf dem
Gerät bleibt bestehen. Mit dem erneuten Hinzufügen des Bildschirms wird er wieder steuerbar.
Soll der Bildschirm <HighlightedText>{remove_display_name}</HighlightedText> wirklich gelöscht werden?
Dadurch wird es von diesem Controller nicht mehr erreichbar. Die Installation auf dem Gerät bleibt
bestehen. Mit dem erneuten Hinzufügen des Bildschirms wird er wieder steuerbar.
</div>
<div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={popup_close_function}>Abbrechen</Button>
<Button className="button space font-bold" click_function={popup_close_function}>
Abbrechen
</Button>
<Button
hover_bg="bg-red-400"
active_bg="bg-red-500"
className="px-4 flex text-red-400 hover:text-stone-100"
click_function={() => {
remove_display(display_id);
className="error button space"
click_function={async () => {
popup_close_function();
await remove_display(display_id);
}}>Löschen</Button
>
</div>
{/snippet}
{#snippet add_new_display_popup(existing_display_id: string | null = null)}
{#snippet display_popup(existing_display_id: string | null = null)}
<TextInput
focused_on_start
bind:current_value={text_inputs_valid.name.value}
bind:current_valid={text_inputs_valid.name.valid}
title="Anzeigename"
placeholder="z.B. Beamer vorne links"
is_valid_function={(input: string) => {
is_valid_function={async (input: string) => {
if (!!existing_display_id) {
if (input === get_display_by_id(existing_display_id, $displays)?.name)
if (input === (await get_display_by_id(existing_display_id))?.name)
return [true, 'Gültiger Name'];
}
if (input.length === 0 || input.length > 50) return [false, 'Ungültige Länge'];
if (is_display_name_taken(input)) return [false, 'Name bereits verwendet'];
if (await is_display_name_taken(input)) return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
enter_mode="focus_next"
/>
<div class="flex flex-row gap-2">
<TextInput
@@ -151,17 +271,28 @@
: [false, 'Ungültige IP-Adresse'];
}}
className="grow"
enter_mode="focus_next"
/>
<div class="flex items-end shrink-0">
<Button disabled={!text_inputs_valid.ip.valid} className="px-4 gap-2" bg="bg-stone-750"
><Radio /> Ping</Button
<Button
disabled={!text_inputs_valid.ip.valid}
className="px-4 gap-2"
bg="bg-stone-750"
click_function={async () => {
const status = await ping_ip(text_inputs_valid.ip.value);
notifications.push(
'info',
`Ping '${text_inputs_valid.ip.value}'`,
`Aktueller Zustand: ${display_status_to_info(status)}`
);
}}><Radio /> Ping</Button
>
</div>
</div>
<TextInput
bind:current_value={text_inputs_valid.mac.value}
bind:current_valid={text_inputs_valid.mac.valid}
title="MAC-Adresse (optional)"
title="MAC-Adresse (optional, wird zum aufwecken des Displays benötigt)"
placeholder="z.B. D4:81:A6:C4:BF:3F"
is_valid_function={(input: string) => {
return input === ''
@@ -170,6 +301,10 @@
? [true, 'Gültige MAC-Adresse']
: [false, 'Ungültige MAC-Adresse'];
}}
enter_mode="submit"
enter_function={async () => {
await finalize_add_edit_display(existing_display_id);
}}
/>
<div class="flex flex-row gap-2 justify-end pt-2">
{#if !!existing_display_id}
@@ -180,8 +315,8 @@
disabled={!all_text_inputs_valid()}
className="{!!existing_display_id ? 'px-4' : 'pl-3 pr-4 gap-2'} font-bold"
bg="bg-stone-650"
click_function={() => {
finalize_add_edit_display(existing_display_id);
click_function={async () => {
await finalize_add_edit_display(existing_display_id);
}}
>{#if !!existing_display_id}
Speichern
@@ -192,28 +327,77 @@
</div>
{/snippet}
<main class="bg-stone-900 h-dvh w-dvw text-stone-200 px-4 py-2 gap-2 grid grid-rows-[3rem_auto]">
<!-- {@html SplashScreen} -->
{#snippet settings_popup()}
<div class="flex flex-col gap-2 pl-1">
<span class="font-bold text-lg">Vorschau-Verhalten</span>
<div class="flex flex-col gap-2 ml-2">
<span class="text-stone-400 text-sm max-w-prose"
>Die Vorschau eines Bildschirms ist das Bild, welches links neben dem Display-Namen zu sehen
ist. Es zeigt relativ aktuell das an, was auf dem jeweiligen Bildschirm zu sehen ist.</span
>
<div class="flex flex-row justify-between items-center">
<span>Aktualisierungs-Verhalten</span>
<Button
className="gap-3 pl-4 pr-3 w-35"
menu_options={(['never', 'normal', 'always'] as const).map((mode) => ({
icon: mode === $preview_settings.mode ? SquareCheckBig : Square,
name: get_display_preview_mode(mode),
on_select: async () => await change_preview_mode(mode)
}))}>{get_display_preview_mode($preview_settings.mode)} <ChevronDown /></Button
>
</div>
<div class="flex flex-row justify-between items-center">
<span>Intervall zwischen den Aktualisierungs-Anfragen</span>
<NumberSettingInput
disabled={$preview_settings.mode === 'never'}
number_setting={$preview_settings.retry_seconds}
on_change={(new_value: number) => {
$preview_settings.retry_seconds.now = new_value;
}}
/>
</div>
<div class="flex flex-row justify-between items-center max-w-full gap-8">
<span class="">Anzahl der änderungslosen Aktualisierungen bis pausiert wird</span>
<NumberSettingInput
disabled={$preview_settings.mode !== 'normal'}
number_setting={$preview_settings.retry_count}
on_change={(new_value: number) => {
$preview_settings.retry_count.now = new_value;
}}
/>
</div>
</div>
</div>
<div class="flex justify-end pt-4">
<Button click_function={popup_close_function} className="px-4">Schließen</Button>
</div>
{/snippet}
<main class="bg-stone-900 h-dvh w-dvw text-stone-200 px-4 py-2 gap-2 grid grid-rows-[3rem_auto]">
<div class="w-[calc(100dvw-(8*var(--spacing)))] flex justify-between">
<span class="text-4xl font-bold content-center pl-1"> PLG MuDiCS </span>
<Button
className="aspect-square"
bg="bg-stone-800"
div_class="aspect-square"
menu_options={[
{
icon: Plus,
name: 'Neuen Bildschirm hinzufügen',
name: 'Neuen Bildschirm Hinzufügen',
on_select: show_new_display_popup
},
{
icon: Settings,
name: 'Weitere Einstellungen'
name: 'Einstellungen',
on_select: show_settings_popup
},
{
icon: Info,
name: 'Über',
on_select: show_about_popup
}
]}
>
<Settings></Settings>
<Menu />
</Button>
</div>
<div class="w-[calc(100dvw-(8*var(--spacing)))] grid grid-cols-2 gap-2">
@@ -228,5 +412,10 @@
<FileView />
</div>
</div>
<PopUp content={popup_content} close_function={popup_close_function} />
<PopUp
content={popup_content}
close_function={popup_close_function}
className="bg-white/10"
snippet_container_class="min-w-115"
/>
</main>
@@ -0,0 +1,303 @@
<script lang="ts">
import {
ArrowBigLeft,
ArrowBigRight,
ChevronDown,
Keyboard,
Power,
PowerOff,
Presentation,
SquareTerminal,
TextAlignStart,
TrafficCone,
Globe
} from 'lucide-svelte';
import Button from '$lib/components/Button.svelte';
import PopUp from '$lib/components/PopUp.svelte';
import type { PopupContent } from '$lib/ts/types';
import KeyInput from './KeyInput.svelte';
import {
send_keyboard_input,
show_blackscreen,
shutdown,
startup,
show_html
} from '$lib/ts/api_handler';
import {
get_display_by_id,
no_active_display_selected,
online_displays,
run_on_all_selected_displays
} from '$lib/ts/stores/displays';
import { selected_display_ids } from '$lib/ts/stores/select';
import TipTapInput from './TipTapInput.svelte';
import { db } from '$lib/ts/database';
import { liveQuery, type Observable } from 'dexie';
import TextInput from '$lib/components/TextInput.svelte';
let all_display_states: Observable<'on' | 'off' | 'mixed'> | undefined = $state();
$effect(() => {
const ids = $selected_display_ids;
all_display_states = liveQuery(() => all_state(ids));
});
let popup_content: PopupContent = $state({
open: false,
snippet: null,
title: '',
});
let current_text = $state('');
function popup_close_function() {
popup_content.open = false;
}
const show_send_keys_popup = () => {
popup_content = {
open: true,
snippet: send_keys_popup,
title: 'Tastatur-Eingaben Senden',
title_icon: Keyboard,
window_class: 'h-full'
};
};
const show_text_popup = () => {
popup_content = {
open: true,
snippet: text_popup,
title: 'Text Anzeigen',
title_icon: TextAlignStart,
window_class: 'size-full'
};
};
const show_website_popup = () => {
popup_content = {
open: true,
snippet: website_popup,
title: 'Webseite Anzeigen',
window_class: 'w-xl',
title_icon: Globe,
};
};
async function all_state(selected_display_ids: string[]): Promise<'on' | 'off' | 'mixed'> {
const selected_displays = await Promise.all(
selected_display_ids.map(async (id) => await get_display_by_id(id))
);
const selected_display_states = selected_displays.map((display) => display?.status ?? '');
if (selected_display_states.every((state) => state === 'app_online')) {
return 'on';
} else if (selected_display_states.every((state) => state !== 'app_online')) {
return 'off';
} else {
return 'mixed';
}
}
async function ask_shutdown() {
popup_content = {
open: true,
snippet: ask_shutdown_popup,
title: 'Bildschirm Herunterfahren',
title_icon: PowerOff,
};
}
async function shutdown_action() {
popup_content.open = false;
await run_on_all_selected_displays((d) => {
shutdown(d.ip); // no await here because we want to be fast
db.displays.update(d.id, { status: 'app_offline', preview: { currently_updating: false, url: null} });
}, false);
}
async function startup_action() {
await run_on_all_selected_displays(
async (d) => {
if (!d.mac) return;
startup(d.mac); // no await here because we want to be fast
db.displays.update(d.id, { status: 'app_offline' });
},
false,
false
);
}
async function send_single_key_press(key: string, action: 'press' | 'release') {
await run_on_all_selected_displays((d) => send_keyboard_input(d.ip, [{ key, action }]));
}
let website_url = $state('');
let website_url_valid = $state(false);
function validate_website_url(url: string): [boolean, string] {
if (url === '') return [true, ''];
const regex = /^https?:\/\/[\w\-]+(\.[\w\-]+)+([\w\-\._~:/?#\[\]@!$&'\(\)\*\+,;=.])*/;
if (regex.test(url)) {
return [true, ''];
}
return [false, 'Ungültige URL'];
}
async function send_website() {
popup_content.open = false;
await run_on_all_selected_displays((d) =>
show_html(d.ip, `<iframe src="${website_url}"></iframe>`)
);
}
</script>
{#snippet website_popup()}
<div class="flex flex-col gap-2">
<TextInput
title="URL (mit http:// oder https://)"
placeholder="https://example.com"
bind:current_value={website_url}
bind:current_valid={website_url_valid}
is_valid_function={validate_website_url}
enter_mode="submit"
enter_function={send_website}
/>
<div class="flex flex-row justify-end gap-2">
<Button className="button space font-bold" click_function={popup_close_function}>
Abbrechen
</Button>
<Button
click_function={send_website}
disabled={!website_url_valid || website_url === ''}
className="button success space">Anzeigen</Button
>
</div>
</div>
{/snippet}
{#snippet ask_shutdown_popup()}
<p>Bist du sicher, dass du alle ausgewählten Displays herunterfahren möchtest?</p>
<div class="flex flex-row justify-end gap-2">
<Button className="button space font-bold" click_function={() => (popup_content.open = false)}>
Abbrechen
</Button>
<Button click_function={shutdown_action} className="button error space">Herunterfahren</Button>
</div>
{/snippet}
{#snippet send_keys_popup()}
<KeyInput {popup_close_function}/>
{/snippet}
{#snippet text_popup()}
<TipTapInput bind:text={current_text}/>
{/snippet}
<div class="grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl min-w-0">
<div class="text-xl font-bold pl-3 content-center bg-stone-700 rounded-t-2xl truncate min-w-0">
Bildschirme Steuern
</div>
<div class="relative flex flex-col gap-2 p-2 overflow-auto">
<div class="flex flex-row justify-between gap-2">
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 w-75 justify-normal">
<button
title="Vorherige Folie (Pfeil nach Links) [gedrückt halten möglich]"
class="px-9 bg-stone-700 {$selected_display_ids.length === 0
? 'text-stone-500 cursor-not-allowed'
: 'hover:bg-stone-600 active:bg-stone-500 cursor-pointer'} py-2 rounded-xl flex justify-center items-center transition-colors duration-200"
disabled={$selected_display_ids.length === 0}
onmousedown={async () => {
await send_single_key_press('ArrowLeft', 'press');
}}
onmouseup={async () => {
await send_single_key_press('ArrowLeft', 'release');
}}
>
<ArrowBigLeft />
</button>
<button
title="Vorherige Folie (Pfeil nach Links) [gedrückt halten möglich]"
class="px-9 bg-stone-700 {$selected_display_ids.length === 0
? 'text-stone-500 cursor-not-allowed'
: 'hover:bg-stone-600 active:bg-stone-500 cursor-pointer'} py-2 rounded-xl flex justify-center items-center transition-colors duration-200"
disabled={$selected_display_ids.length === 0}
onmousedown={async () => {
await send_single_key_press('ArrowRight', 'press');
}}
onmouseup={async () => {
await send_single_key_press('ArrowRight', 'release');
}}
>
<ArrowBigRight />
</button>
</div>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
click_function={show_text_popup}
disabled={no_active_display_selected($selected_display_ids, $online_displays)}
><TextAlignStart /> Text Anzeigen</Button
>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={no_active_display_selected($selected_display_ids, $online_displays)}
click_function={show_website_popup}><Globe /> Webseite Anzeigen</Button
>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={no_active_display_selected($selected_display_ids, $online_displays)}
click_function={async () => {
await run_on_all_selected_displays((d) => show_blackscreen(d.ip));
}}><Presentation />Blackout</Button
>
<div class="flex flex-row justify-normal">
<Button className="rounded-r-none pl-3 flex gap-3 grow w-65 justify-normal" disabled>
<TrafficCone /> Fallback-Bild Anzeigen
</Button>
<Button className="rounded-l-none flex grow-0 w-10" disabled><ChevronDown /></Button>
</div>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={no_active_display_selected($selected_display_ids, $online_displays)}
click_function={show_send_keys_popup}><Keyboard /> Tastatur-Eingaben Senden</Button
>
</div>
<div class="flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2">
<Button
className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
disabled={$all_display_states === 'on' ||
no_active_display_selected($selected_display_ids, $online_displays)}
click_function={startup_action}
>
<Power /> Bildschirm Hochfahren
</Button>
<Button
className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
disabled={$all_display_states === 'off' ||
no_active_display_selected($selected_display_ids, $online_displays)}
click_function={ask_shutdown}
>
<PowerOff /> Bildschirm Herunterfahren</Button
>
</div>
<Button className="px-3 flex gap-3 w-full xl:w-75 justify-normal" disabled>
<SquareTerminal />
Shell-Befehl Ausführen
</Button>
</div>
</div>
<PopUp
content={popup_content}
close_function={popup_close_function}
className="rounded-b-2xl"
snippet_container_class="size-full"
/>
</div>
</div>
+297
View File
@@ -0,0 +1,297 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import {
all_displays_of_group_selected,
get_display_by_id,
set_select_for_group,
local_displays,
update_local_displays,
update_db_displays,
get_display_groups
} from '$lib/ts/stores/displays';
import {
change_height,
current_height,
dnd_flip_duration_ms,
is_display_drag,
is_group_drag,
next_height_step_size,
pinned_display_id
} from '$lib/ts/stores/ui_behavior';
import { type Display, type DisplayGroup, type MenuOption } from '$lib/ts/types';
import Button from '$lib/components/Button.svelte';
import OnlineState from '../lib/components/OnlineState.svelte';
import { Menu, Pencil, PinOff, Trash2, VideoOff, ZoomIn, ZoomOut } from 'lucide-svelte';
import { selected_display_ids } from '$lib/ts/stores/select';
import { dragHandleZone } from 'svelte-dnd-action';
import DisplayGroupObject from '../lib/components/DisplayGroupObject.svelte';
import { Pane, Splitpanes } from 'svelte-splitpanes';
import HighlightedText from '$lib/components/HighlightedText.svelte';
import { liveQuery, type Observable } from 'dexie';
import { onMount } from 'svelte';
import { flip } from 'svelte/animate';
import { cubicOut } from 'svelte/easing';
let {
handle_display_deletion,
handle_display_editing
}: {
handle_display_deletion: (display_id: string) => void;
handle_display_editing: (display_id: string) => void;
} = $props();
let displays_scroll_box: HTMLElement;
let pinned_display: Observable<Display | null> | undefined = $state();
$effect(() => {
const pdi = $pinned_display_id;
pinned_display = liveQuery(() => get_display_by_id(pdi || ''));
});
let display_groups = liveQuery(() => get_display_groups());
let display_id_groups = liveQuery(() => update_local_displays());
$effect(() => {
const current_displays = $display_id_groups;
if (!$is_group_drag && !$is_display_drag && current_displays) {
$local_displays = current_displays;
}
});
let all_groups_selected: Observable<boolean> | undefined = $state();
$effect(() => {
const d = $display_groups;
const sdi = $selected_display_ids;
all_groups_selected = liveQuery(() => all_selected(d || [], sdi));
});
let last_pinned_pane_size: number = 45;
let pinned_pane_size: number = $state(last_pinned_pane_size);
function close_pinned_display() {
last_pinned_pane_size = pinned_pane_size;
pinned_pane_size = 0;
}
function get_display_menu_options(display_id: string): MenuOption[] {
return [
{
icon: Pencil,
name: 'Bildschirm bearbeiten',
on_select: () => {
handle_display_editing(display_id);
}
},
{
icon: Trash2,
name: 'Bildschirm löschen',
class: 'text-red-400 hover:text-stone-200 hover:!bg-red-400 active:!bg-red-500',
on_select: () => {
handle_display_deletion(display_id);
}
}
];
}
async function toggle_all_selected_displays(current_displays: DisplayGroup[]) {
const new_value = !$all_groups_selected;
for (const display_group of current_displays) {
await set_select_for_group(display_group.id, new_value);
}
}
async function all_selected(
current_displays: DisplayGroup[],
current_selected_display_ids: string[]
) {
for (const display_group of current_displays) {
if (!(await all_displays_of_group_selected(display_group.id, current_selected_display_ids))) {
return false;
}
}
return true;
}
function handle_splitpane_resize(e: any) {
if (e.detail[0].size === 0) {
$pinned_display_id = null;
pinned_pane_size = last_pinned_pane_size;
}
}
function on_wheel(e: WheelEvent) {
if (!$is_group_drag && !$is_display_drag) return;
if (!displays_scroll_box) return;
// apply custom scroll feature
e.preventDefault();
(displays_scroll_box as HTMLElement).scrollBy?.({
top: e.deltaY,
behavior: 'auto'
});
}
</script>
<svelte:window on:wheel={on_wheel} />
<div class="h-[calc(100dvh-3rem-(6*var(--spacing)))] flex flex-col gap-2">
<Splitpanes
horizontal
theme="mudics-stone-theme"
on:resized={handle_splitpane_resize}
dblClickSplitter={false}
>
{#if $pinned_display_id}
<!-- Pinned Item -->
<Pane maxSize={60} snapSize={20} bind:size={pinned_pane_size}>
<div class="h-full" transition:fade>
<div class="grid grid-rows-[2.5rem_auto] will-change-[height,opacity] h-full">
<div
class="bg-stone-700 rounded-t-2xl flex justify-between w-full p-1 min-w-0 basis-0 flex-1"
>
<span
class="text-xl font-bold pl-2 content-center truncate min-w-0"
title={$pinned_display?.name || '...'}
>
{$pinned_display?.name || '...'}
</span>
<div class="flex flex-row gap-1">
<div class="flex flex-row items-center mr-1">
<span class="text-stone-400"> Aktueller Status: </span>
<OnlineState
selected={false}
status={$pinned_display?.status ?? null}
className="flex items-center px-2"
/>
</div>
<Button
className="aspect-square p-1!"
bg="bg-stone-600"
click_function={(e) => {
e.stopPropagation();
}}
menu_options={get_display_menu_options($pinned_display_id)}
>
<Menu />
</Button>
<Button
title="Bildschirm nicht mehr anpinnen"
className="aspect-square p-1!"
bg="bg-stone-600"
click_function={close_pinned_display}
>
<PinOff />
</Button>
</div>
</div>
<div
class="h-full bg-stone-800 rounded-b-2xl overflow-hidden flex justify-center items-center"
>
{#if $pinned_display?.preview.url}
<img
src={$pinned_display.preview.url}
alt="preview"
class="max-h-full max-w-full object-cover bg-black"
/>
{:else}
<div class="size-full bg-black flex justify-center items-center">
<VideoOff class="size-[20%]" />
</div>
{/if}
</div>
</div>
</div>
</Pane>
{/if}
<Pane>
<div
class="min-h-0 h-full grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl overflow-hidden"
>
<!-- Normal Heading Left -->
<div class="bg-stone-700 flex justify-between w-full p-1 gap-2 min-w-0">
<span class="text-xl font-bold pl-2 content-center truncate min-w-0">
Verbundene Bildschirme
</span>
<div class="flex flex-row gap-1">
<button
class="min-w-40 px-4 rounded-xl cursor-pointer duration-200 transition-colors bg-stone-600"
onclick={async () => await toggle_all_selected_displays($display_groups)}
>
<span>{$all_groups_selected || false ? 'Alle Abwählen' : 'Alle Auswählen'}</span>
</button>
<div class="flex flex-row">
<Button
title="Bildschirme größer darstellen"
className="aspect-square p-1.5! pr-1! rounded-r-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, 1))}
click_function={() => {
change_height('display', 1);
}}
>
<ZoomIn class="size-full" />
</Button>
<Button
title="Bildschirme kleiner darstellen"
className="aspect-square p-1.5! pl-1! rounded-l-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, -1))}
click_function={() => {
change_height('display', -1);
}}
>
<ZoomOut class="size-full" />
</Button>
</div>
</div>
</div>
<div class="min-h-0 overflow-y-auto overflow-x-hidden" bind:this={displays_scroll_box}>
{#if $local_displays.length === 0}
<div class="text-stone-500 px-10 py-6 leading-relaxed text-center">
Es wurden noch keine Bildschirme hinzugefügt. Klicke oben rechts auf
<HighlightedText fg="text-stone-450" className="p-1!">
<Menu class="inline pb-1" />
</HighlightedText>
und
<HighlightedText fg="text-stone-450">Neuen Bildschirm hinzufügen</HighlightedText>.
</div>
{:else}
<div
class="min-h-full p-2 flex flex-col gap-4"
use:dragHandleZone={{
items: $local_displays,
type: 'group',
flipDurationMs: dnd_flip_duration_ms,
dropFromOthersDisabled: true,
dropTargetStyle: { outline: 'none' }
}}
onconsider={(e: CustomEvent) => {
$is_group_drag = true;
$local_displays = e.detail.items;
}}
onfinalize={async (e: CustomEvent) => {
$local_displays = e.detail.items;
await update_db_displays();
$is_group_drag = false;
}}
>
{#each $local_displays as display_id_group (display_id_group.id)}
<!-- Each Group -->
<section class="outline-none">
<!-- out:scale={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
animate:flip={{ duration: dnd_flip_duration_ms, easing: cubicOut }} -->
<DisplayGroupObject
{display_id_group}
{get_display_menu_options}
{close_pinned_display}
/>
</section>
{/each}
</div>
{/if}
</div>
</div>
</Pane>
</Splitpanes>
</div>
+419
View File
@@ -0,0 +1,419 @@
<script lang="ts">
import {
ClipboardPaste,
Download,
FolderPlus,
Pen,
RefreshCcw,
Scissors,
Trash2,
Upload,
ZoomIn,
ZoomOut
} from 'lucide-svelte';
import { change_height, current_height, next_height_step_size } from '$lib/ts/stores/ui_behavior';
import Button from '$lib/components/Button.svelte';
import PathBar from './PathBar.svelte';
import { select, selected_display_ids, selected_file_ids } from '$lib/ts/stores/select';
import {
current_file_path,
get_folder_elements,
get_file_by_id,
run_for_selected_files_on_selected_displays,
update_current_folder_on_selected_displays,
get_displays_where_path_not_exists,
create_path_on_all_selected_displays
} from '$lib/ts/stores/files';
import { slide } from 'svelte/transition';
import InodeElement from '../lib/components/InodeElement.svelte';
import PopUp from '$lib/components/PopUp.svelte';
import { get_file_primary_key, is_folder, type Inode, type PopupContent } from '$lib/ts/types';
import TextInput from '$lib/components/TextInput.svelte';
import {
first_letter_is_valid,
get_accepted_file_type_string,
is_valid_name
} from '$lib/ts/utils';
import { delete_files, rename_file } from '$lib/ts/api_handler';
import HighlightedText from '$lib/components/HighlightedText.svelte';
import { liveQuery, type Observable } from 'dexie';
import { download_file, add_upload, add_sync_recursively } from '$lib/ts/file_transfer_handler';
import { no_active_display_selected, online_displays } from '$lib/ts/stores/displays';
let current_name: string = $state('');
let current_valid: boolean = $state(false);
let display_names_where_path_does_not_exist: string[] = $state([]);
let selected_files: Observable<Inode[]> | undefined = $state();
$effect(() => {
const s = $selected_file_ids;
selected_files = liveQuery(() => get_selected_files(s));
});
let current_folder_elements: Observable<Inode[]> | undefined = $state();
$effect(() => {
const path = $current_file_path,
display_ids = $selected_display_ids;
current_folder_elements = liveQuery(() => get_folder_elements(path, display_ids));
});
let one_file_selected: Observable<boolean> | undefined = $state();
$effect(() => {
const s = $selected_file_ids;
one_file_selected = liveQuery(async () => {
const inode = await get_file_by_id(s[0]);
if (!inode) return false;
return s.length === 1 && is_folder(inode);
});
});
let popup_content: PopupContent = $state({
open: false,
snippet: null,
title: '',
});
let file_input: HTMLInputElement;
async function get_selected_files(selected_file_ids: string[]): Promise<Inode[]> {
try {
const results = await Promise.all(selected_file_ids.map((id) => get_file_by_id(id)));
return results.filter((element) => element !== null);
} catch (e: unknown) {
console.error('Error on generating selected_files');
return [];
}
}
function popup_close_function() {
popup_content.open = false;
}
async function create_new_folder() {
popup_close_function();
const path_with_folder_name = ($current_file_path += current_name.trim() + '/');
await create_path_on_all_selected_displays(path_with_folder_name, $selected_display_ids);
await update_current_folder_on_selected_displays();
}
async function edit_file_name(new_file_name: string) {
popup_close_function();
await run_for_selected_files_on_selected_displays(async (ip: string, file_names: string[]) => {
if (file_names.length !== 1) {
console.error(file_names);
return; // Error
}
await rename_file(ip, $current_file_path, file_names[0], new_file_name);
});
await update_current_folder_on_selected_displays();
}
const show_edit_file_popup = async () => {
const file = await get_file_by_id($selected_file_ids[0]);
if (!file) return;
const file_is_folder = is_folder(file);
const extension = file_is_folder ? '' : '.' + file.name.split('.').at(-1) || '';
current_name = file.name.slice(0, file.name.length - extension.length);
current_valid = true;
popup_content = {
open: true,
snippet: edit_file_name_popup,
title: `${file_is_folder ? 'Ordner' : 'Datei'} umbenennen`,
title_icon: FolderPlus,
snippet_arg: extension,
};
};
const show_new_folder_popup = async () => {
current_name = '';
current_valid = false;
display_names_where_path_does_not_exist = (
await get_displays_where_path_not_exists($current_file_path, $selected_display_ids)
).map((display) => display.name);
popup_content = {
open: true,
snippet: new_folder_popup,
title: 'Neuen Ordner erstellen',
title_icon: FolderPlus,
};
};
const delete_folder_element_popup = () => {
popup_content = {
open: true,
snippet: delete_request_popup,
title: `${$selected_file_ids.length} ${$selected_file_ids.length === 1 ? 'Objekt' : 'Objekte'} wirklich löschen?`,
title_icon: Trash2,
};
};
async function sync_selected_files(
current_selected_file_ids: string[],
selected_display_ids: string[],
current_folder_elements: Inode[]
) {
if (current_selected_file_ids.length === 0 && current_folder_elements.length > 0) {
current_selected_file_ids = current_folder_elements.map((inode) => get_file_primary_key(inode));
}
if (current_selected_file_ids.length === 0) return;
// Mit For-Schleife über ausgewählte Elemente gehen
for (const file_id of current_selected_file_ids) {
await select(selected_file_ids, file_id, 'deselect');
await add_sync_recursively(file_id, selected_display_ids);
}
}
</script>
{#snippet new_folder_popup()}
{#if display_names_where_path_does_not_exist.length > 0}
<span class="leading-relaxed"
>Der aktuelle Pfad <HighlightedText
>{$current_file_path.slice(0, $current_file_path.length - 1)}</HighlightedText
> existiert nicht auf {display_names_where_path_does_not_exist.length === 1
? 'dem Bildschirm'
: 'den Bildschirmen'}
{#each display_names_where_path_does_not_exist as display_name, i}
{#if i !== 0}
,
{/if}
<HighlightedText>{display_name}</HighlightedText>
{/each}. Mit der Erstellung dieses Ordners wird der Pfad automatisch mit leeren Ordnern bis
zum aktuellen Pfad aufgefüllt.
</span>
{/if}
<TextInput
focused_on_start={true}
bind:current_value={current_name}
bind:current_valid
title="Ordnername"
is_valid_function={async (input: string) => {
const trimmed_input = input.trim();
if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge'];
if (!first_letter_is_valid(trimmed_input))
return [false, `Name darf nicht mit ${trimmed_input[0]} beginnen`];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if (($current_folder_elements ?? []).some((e) => e.name === trimmed_input))
return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
enter_mode="submit"
enter_function={create_new_folder}
/>
<div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={create_new_folder} disabled={!current_valid}
>Neuen Ordner erstellen</Button
>
</div>
{/snippet}
{#snippet edit_file_name_popup(extension: string)}
<TextInput
focused_on_start={true}
bind:current_value={current_name}
bind:current_valid
title="Neuer {extension === '' ? 'Ordner' : 'Datei'}name"
is_valid_function={async (input: string) => {
const trimmed_input = input.trim() + extension;
if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge'];
if (!first_letter_is_valid(trimmed_input))
return [false, `Name darf nicht mit ${trimmed_input[0]} beginnen`];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if (
($current_folder_elements ?? []).some(
(e) => e.name === trimmed_input && get_file_primary_key(e) !== $selected_file_ids[0]
)
)
return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
enter_mode="submit"
enter_function={async () => await edit_file_name(current_name.trim() + extension)}
{extension}
/>
<div class="flex flex-row justify-end gap-2">
<Button
className="px-4 font-bold"
click_function={async () => await edit_file_name(current_name.trim() + extension)}
disabled={!current_valid}>{extension === '' ? 'Ordner' : 'Datei'} umbenennen</Button
>
</div>
{/snippet}
{#snippet delete_request_popup()}
<div class="flex flex-col gap-1 h-full min-h-0 grow-0">
<span class="text-stone-400 px-1"
>{`${$selected_file_ids.length === 1 ? 'Folgendes Objekt' : `Folgende ${$selected_file_ids.length} Objekte`} löschen? (Wiederherstellung nicht möglich)`}</span
>
<div class="flex flex-col gap-2 overflow-y-auto h-full min-h-0 grow-0">
{#each $selected_files || [] as file}
<InodeElement {file} not_interactable />
{/each}
</div>
</div>
<div class="flex flex-row justify-end gap-2">
<Button className="button space font-bold" click_function={popup_close_function}>
Abbrechen
</Button>
<Button
className="button error space"
click_function={async () => {
popup_close_function();
await run_for_selected_files_on_selected_displays(
async (ip: string, file_names: string[]) => {
await delete_files(ip, $current_file_path, file_names);
}
);
await update_current_folder_on_selected_displays();
}}>Löschen</Button
>
</div>
{/snippet}
<input
class="hidden"
type="file"
bind:this={file_input}
multiple
accept={get_accepted_file_type_string()}
onchange={(e) =>
add_upload((e.target as HTMLInputElement).files!, $selected_display_ids, $current_file_path)}
/>
<div class="bg-stone-800 h-full rounded-2xl grid grid-rows-[2.5rem_1fr] min-h-0">
<div class="bg-stone-700 flex justify-between w-full p-1 rounded-t-2xl min-w-0 gap-2">
<span class="text-xl font-bold pl-2 content-center truncate min-w-0">
Dateien Anzeigen und Verwalten
</span>
<div class="flex flex-row">
<Button
title="Dateien größer darstellen"
className="aspect-square p-1.5! pr-1! rounded-r-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('file', $current_height, 1))}
click_function={() => {
change_height('file', 1);
}}
>
<ZoomIn class="size-full" />
</Button>
<Button
title="Dateien kleiner darstellen"
className="aspect-square p-1.5! pl-1! rounded-l-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('file', $current_height, -1))}
click_function={() => {
change_height('file', -1);
}}
>
<ZoomOut class="size-full" />
</Button>
</div>
</div>
<div class="flex flex-col gap-2 p-2 overflow-hidden relative rounded-b-2xl">
<div class="flex flex-col gap-2 p-2 bg-stone-750 rounded-xl">
<PathBar />
<div class="flex flex-row justify-between gap-6">
<div class="flex flex-row gap-2 shrink-0">
<Button
title="Neuen Ordner erstellen (Neuen Ordner mit ausgewählten Objekten erstellen)"
className="px-3 flex"
click_function={show_new_folder_popup}
disabled={no_active_display_selected($selected_display_ids, $online_displays)}><FolderPlus /></Button
>
<div class="border border-stone-700 my-1"></div>
<Button
title="Datei(en) hochladen"
className="px-3 flex"
click_function={() => {
if (file_input) file_input.click();
}}
disabled={no_active_display_selected($selected_display_ids, $online_displays)}><Upload /></Button
>
<Button
title="Ausgewählte Datei herunterladen"
className="px-3 flex"
click_function={async () =>
await download_file($selected_file_ids[0], $selected_display_ids)}
disabled={!$one_file_selected}><Download /></Button
>
<div class="border border-stone-700 my-1"></div>
<Button
title="Aktuellen Ordner / Ausgewählte Datei(en) zwischen Bildschirmen synchronisieren"
className="px-3 flex gap-3"
click_function={async () =>
await sync_selected_files(
$selected_file_ids,
$selected_display_ids,
$current_folder_elements ?? []
)}
disabled={no_active_display_selected($selected_display_ids, $online_displays)}
><RefreshCcw />
<span class="hidden 2xl:flex">Synchronisieren</span>
</Button>
</div>
<div class="flex flex-row gap-2">
<Button
title="Ausgewählte Datei(en) ausschneiden"
className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Scissors /></Button
>
<Button
title="Ausgewählte Datei(en) einfügen"
className="px-3 flex"
disabled={no_active_display_selected($selected_display_ids, $online_displays)}
>
<ClipboardPaste />
</Button>
<div class="border border-stone-700 my-1"></div>
<Button
title="Ausgewählte Datei umbenennen"
className="px-3 flex"
click_function={show_edit_file_popup}
disabled={$selected_file_ids.length !== 1}><Pen /></Button
>
<Button
title="Ausgewählte Datei(en) löschen"
hover_bg="bg-red-400"
active_bg="bg-red-500"
className="px-3 flex"
disabled={$selected_file_ids.length === 0}
click_function={delete_folder_element_popup}><Trash2 /></Button
>
</div>
</div>
</div>
<div class="min-h-0 h-full overflow-y-auto overflow-x-hidden bg-stone-750 rounded-xl">
<div class="flex flex-col gap-2 p-2 min-h-0 max-w-full">
{#if no_active_display_selected($selected_display_ids, $online_displays)}
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center">
Es sind keine Bildschirme ausgewählt.
</span>
{:else}
{#each $current_folder_elements ?? [] as folder_element (get_file_primary_key(folder_element))}
<section in:slide={{ duration: 100 }} class="outline-none">
<InodeElement file={folder_element} />
</section>
{/each}
{#if ($current_folder_elements ?? []).length === 0}
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center max-w-full">
Es existieren keine Dateien auf {$selected_display_ids.length === 1
? 'dem ausgewähltem Bildchirm'
: 'den ausgewählten Bildschirmen'} im aktuellen Ordner. Klicke auf <HighlightedText
bg="bg-stone-700"
fg="text-stone-400"
className="p-1!"><Upload class="inline pb-1" /></HighlightedText
> um Datei(en) hochzuladen.
</span>
{/if}
{/if}
</div>
</div>
<PopUp
content={popup_content}
close_function={popup_close_function}
className="rounded-b-2xl"
snippet_container_class="overflow-hidden min-w-90"
/>
</div>
</div>
+170
View File
@@ -0,0 +1,170 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { get_selectable_color_classes } from '$lib/ts/stores/ui_behavior';
import { fade } from 'svelte/transition';
import { run_on_all_selected_displays } from '$lib/ts/stores/displays';
import { send_keyboard_input } from '$lib/ts/api_handler';
import { ArrowDownToLine, ArrowUpFromLine, Grid2x2, Grid2X2, Option } from 'lucide-svelte';
import Button from '$lib/components/Button.svelte';
import { onDestroy } from 'svelte';
let {
popup_close_function
}: {
popup_close_function: () => void;
} = $props();
let active = $state(false);
const current_keys: string[] = $state([]);
let last_keys: { id: number; key: string }[] = $state([]);
let seq = 0;
let el: HTMLDivElement;
function add_to_last_keys(name: string) {
const id = ++seq;
// Neueste oben
last_keys = [{ id, key: name }, ...last_keys].slice(0, 6);
setTimeout(() => {
last_keys = last_keys.filter((e) => e.id !== id);
}, 1500);
}
async function on_keyboard_input(e: KeyboardEvent, key_down: boolean) {
if (!active) return;
const id = e.code;
if (!id) return;
e.preventDefault();
e.stopPropagation();
if (e.repeat) return;
await on_key(id, key_down);
}
async function on_key(key: string, key_down: boolean) {
if (key_down) {
if (current_keys.includes(key)) return;
current_keys.push(key);
} else {
const index = current_keys.indexOf(key);
if (index === -1) return;
current_keys.splice(index, 1);
}
const action: 'press' | 'release' = key_down ? 'press' : 'release';
add_to_last_keys(action.toUpperCase() + ' ' + key);
await run_on_all_selected_displays((d) => send_keyboard_input(d.ip, [{ key, action }]), true);
}
async function release_all_pressed_keys() {
const inputs: { key: string; action: 'press' | 'release' }[] = [];
for (let i = current_keys.length - 1; i >= 0; i--) {
inputs.push({ key: current_keys[i], action: 'release' });
current_keys.splice(i, 1);
}
await run_on_all_selected_displays((d) => send_keyboard_input(d.ip, inputs), true);
}
onDestroy(() => {
release_all_pressed_keys();
});
</script>
<div class="flex flex-row gap-2 overflow-hidden h-full">
<div
role="textbox"
tabindex="0"
bind:this={el}
onclick={() => {
if (active) {
el.blur();
} else {
el.focus();
active = true;
}
}}
onblur={async () => {
active = false;
await release_all_pressed_keys();
}}
onkeydown={(e) => on_keyboard_input(e, true)}
onkeyup={(e) => on_keyboard_input(e, false)}
class="flex justify-center items-center text-center grow py-2 px-8 w-70 h-full cursor-pointer rounded-xl transition-colors duration-200 select-none {get_selectable_color_classes(
active,
{
bg: true,
hover: true,
active: true,
text: true
}
)}"
>
{active ? 'Erfassung aktiv' : 'Hier für Erfassung klicken'}
</div>
<div
class="relative flex flex-col gap-1 text-sm w-40 p-2 bg-stone-750 rounded-xl overflow-hidden h-full"
>
{#each last_keys as item (item.id)}
<span
class="flex flex-row gap-2 {item.key.split(' ')[0] === 'PRESS'
? 'text-sky-600'
: 'text-lime-600'}"
animate:flip={{ duration: 120 }}
in:fade={{ duration: 120 }}
out:fade={{ duration: 200 }}
>
{#if item.key.split(' ')[0] === 'PRESS'}
<ArrowDownToLine />
{:else}
<ArrowUpFromLine />
{/if}
{item.key.split(' ').at(-1)}
</span>
{/each}
<div
class="absolute bottom-0 right-0 left-0 h-5 bg-linear-to-b from-transparent to-stone-750"
></div>
</div>
<div class="flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2">
<button
title="Windows-/Meta-Taste [gedrückt halten möglich]"
class="px-3 bg-stone-700 py-2 gap-2 rounded-xl flex items-center transition-colors duration-200 hover:bg-stone-600 active:bg-stone-500 cursor-pointer"
onmousedown={async (e) => {
e.preventDefault();
await on_key('MetaLeft', true);
}}
onmouseup={async () => {
await on_key('MetaLeft', false);
}}
>
<Grid2x2 /> Meta
</button>
<button
title="Alt-Taste [gedrückt halten möglich]"
class="px-3 bg-stone-700 py-2 gap-2 rounded-xl flex items-center transition-colors duration-200 hover:bg-stone-600 active:bg-stone-500 cursor-pointer"
onmousedown={async (e) => {
e.preventDefault();
await on_key('AltLeft', true);
}}
onmouseup={async () => {
await on_key('AltLeft', false);
}}
>
<Option /> Alt
</button>
</div>
<Button
div_class="mt-2 justify-end"
className="px-4 font-bold"
click_function={popup_close_function}>Fertig</Button
>
</div>
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import Button from '$lib/components/Button.svelte';
import { X } from 'lucide-svelte';
import { notifications } from '$lib/ts/stores/notification';
</script>
<div
data-testid="notification"
class="fixed flex flex-col gap-2 top-[41%] right-2 left-2 md:top-auto md:left-auto md:bottom-2 md:w-100 z-50"
>
{#each $notifications as n (n.id)}
<div
transition:fade={{ duration: 200 }}
class="p-2 pl-4 pb-3 rounded-lg shadow-xl/30 text-white flex flex-col gap-2 overflow-hidden relative border border-black/20 {n.className}"
class:bg-red-900={n.type === 'error'}
class:bg-green-900={n.type === 'success'}
class:bg-sky-900={n.type === 'info'}
style="--dur: {n.duration}ms"
>
<div class="flex flex-row justify-between">
<span class="text-xl font-bold flex items-center">{n.title}</span>
<Button
click_function={() => notifications.remove(n.id)}
className="p-2"
bg="bg-stone-900/50"
hover_bg="bg-stone-600/70"
active_bg="bg-stone-500/80"><X /></Button
>
</div>
<span class="max-h-[60vh] overflow-y-auto overflow-x-hidden whitespace-pre-line max-w-full break-normal wrap-anywhere">{n.message}</span>
<div class="absolute inset-x-0 bottom-0 h-1 bg-white/25">
<div class="block h-full w-full bg-white/80 origin-left animate-progress-bar"></div>
</div>
</div>
{/each}
</div>
<style>
@keyframes progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.animate-progress-bar {
animation: progress var(--dur) linear forwards;
transform-origin: left;
}
</style>
@@ -1,16 +1,17 @@
<script lang="ts">
import { ChevronRight, House } from 'lucide-svelte';
import Button from './Button.svelte';
import Button from '$lib/components/Button.svelte';
import { onDestroy, onMount } from 'svelte';
import type { MenuOption } from '../ts/types';
import { change_file_path, current_file_path } from '../ts/stores/files';
import { fade } from 'svelte/transition';
import type { MenuOption } from '$lib/ts/types';
import { change_file_path, current_file_path } from '$lib/ts/stores/files';
import { flip } from 'svelte/animate';
import { cubicOut } from 'svelte/easing';
let { bg = 'bg-stone-700' } = $props<{
let {
bg = 'bg-stone-700'
}: {
bg?: string;
}>();
} = $props();
let outside_container: HTMLDivElement;
let inside_container: HTMLDivElement;
@@ -79,21 +80,21 @@
out.push({
name: '  '.repeat(i) + hidden_folders[i],
class: 'truncate max-w-80',
on_select: () => {
open_path(i + 1, path);
on_select: async () => {
await open_path(i + 1, path);
}
});
}
return out;
}
function open_path(index_of_all_folders: number, path: string) {
async function open_path(index_of_all_folders: number, path: string) {
let new_path = '/';
const all_folders = get_folders(path);
for (let i = 0; i < index_of_all_folders; i++) {
new_path += all_folders[i] + '/';
}
change_file_path(new_path);
await change_file_path(new_path);
}
onMount(() => {
@@ -113,11 +114,14 @@
<Button
className="py-1 shrink-0 grow-0 w-10"
{bg}
click_function={(e) => {
open_path(0, $current_file_path);
click_function={async () => {
await open_path(0, $current_file_path);
}}
>
<House class="size-full transition-all duration-100" strokeWidth={$current_file_path === '/' ? 2.7 : 2}/>
<House
class="size-full transition-all duration-100"
strokeWidth={$current_file_path === '/' ? 2.7 : 2}
/>
</Button>
</div>
{#if cut_folders !== 0}
@@ -144,8 +148,8 @@
? 'max-w-80 font-bold'
: 'max-w-30'}"
{bg}
click_function={(e) => {
open_path(cut_folders + i + 1, $current_file_path);
click_function={async () => {
await open_path(cut_folders + i + 1, $current_file_path);
}}
>
<ChevronRight class="shrink-0 text-stone-500 h-full" />
@@ -0,0 +1,257 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Editor } from '@tiptap/core';
import { StarterKit } from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import {
Baseline,
Bold,
Highlighter,
Italic,
PaintBucket,
QrCode,
Strikethrough
} from 'lucide-svelte';
import Button from '$lib/components/Button.svelte';
import { run_on_all_selected_displays } from '$lib/ts/stores/displays';
import { show_html } from '$lib/ts/api_handler';
import { get_selectable_color_classes } from '$lib/ts/stores/ui_behavior';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-text-style';
import Highlight from '@tiptap/extension-highlight';
let {
text = $bindable()
}: {
text: string;
} = $props();
type TextEditOption = {
onclick: () => void;
is_selected: () => boolean;
icon: typeof Bold;
title: string;
color?: ColorElement;
};
type ColorElement = {
color_string: () => string;
color_picker: () => void;
};
type ColorState = {
el: HTMLInputElement | null;
value: string;
};
let element = $state<HTMLElement>();
let editor_state = $state<{ editor: Editor | null }>({ editor: null });
let color_states: { text: ColorState; highlight: ColorState; bg: ColorState } = $state({
text: { el: null, value: '#b91c1c' },
highlight: { el: null, value: '#0c4a6e' },
bg: { el: null, value: '#1c1917' }
});
const text_edit_options: TextEditOption[][] = [
[
{
onclick: () => editor_state.editor?.chain().focus().toggleBold().run(),
is_selected: () => editor_state.editor?.isActive('bold') ?? false,
title: 'Fett (STRG+B)',
icon: Bold
},
{
onclick: () => editor_state.editor?.chain().focus().toggleItalic().run(),
is_selected: () => editor_state.editor?.isActive('italic') ?? false,
title: 'Kursiv (STRG+I)',
icon: Italic
},
{
onclick: () => editor_state.editor?.chain().focus().toggleStrike().run(),
is_selected: () => editor_state.editor?.isActive('strike') ?? false,
title: 'Durchgestrichen',
icon: Strikethrough
}
],
[
{
onclick: () => editor_state.editor?.chain().focus().setColor(color_states.text.value).run(),
is_selected: () =>
editor_state.editor?.isActive('textStyle', { color: color_states.text.value }) ?? false,
icon: Baseline,
title: 'Textfarbe',
color: get_color_element(color_states.text)
},
{
onclick: () =>
editor_state.editor
?.chain()
.focus()
.toggleHighlight({ color: color_states.highlight.value })
.run(),
is_selected: () =>
editor_state.editor?.isActive('highlight', { color: color_states.highlight.value }) ??
false,
icon: Highlighter,
title: 'Markierungsfarbe',
color: get_color_element(color_states.highlight)
},
{
onclick: () => color_states.bg.el?.click(),
is_selected: () => false,
title: 'Hintergrundfarbe',
icon: PaintBucket
}
]
];
function get_color_element(color_state: ColorState) {
return {
color_string: () => color_state.value,
color_picker: () => color_state.el?.click()
};
}
async function show_text() {
const html =
editor_state.editor?.getHTML() +
`<style>:root {--background-color: ${color_states.bg.value} !important;}
</style>`;
await run_on_all_selected_displays((d) => show_html(d.ip, html));
}
onMount(() => {
editor_state.editor = new Editor({
element: element,
extensions: [
StarterKit,
Placeholder.configure({
placeholder: 'Text hier eingeben ...'
}),
TextStyle,
Color,
Highlight.configure({
multicolor: true
})
],
content: text,
onTransaction: ({ editor }) => {
// Increment the state signal to force a re-render
editor_state = { editor };
},
autofocus: true
});
editor_state.editor.commands.selectAll();
});
onDestroy(() => {
if (editor_state.editor) text = editor_state.editor.getHTML();
editor_state.editor?.destroy();
});
</script>
{#each Object.values(color_states) as color_state}
<input type="color" bind:this={color_state.el} bind:value={color_state.value} class="hidden" />
{/each}
<div data-testid="text-popup" class="flex flex-row gap-2 size-full">
<div
class="rounded-xl size-full shrink min-w-0 flex"
style="background-color: {color_states.bg.value};"
>
<div bind:this={element} class="size-full overflow-auto px-3 py-2"></div>
</div>
<div class="flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2">
{#each text_edit_options as edit_row}
<div class="flex flex-row gap-1">
{#each edit_row as option}
<div class="flex flex-row">
<button
title={option.title}
onclick={option.onclick}
class="p-1 {option.color
? 'rounded-l-xl'
: 'rounded-xl'} cursor-pointer duration-200 transition-colors {get_selectable_color_classes(
option.is_selected(),
{
bg: true,
hover: true,
active: true,
text: true
},
-100,
50
)}"
>
{#if option.icon}
{@const Icon = option.icon}
<Icon class="h-7 w-7" />
{/if}
</button>
{#if option.color}
<button
aria-label="Color"
onclick={option.color.color_picker}
title={option.title}
class="flex p-1 rounded-r-xl cursor-pointer duration-200 transition-colors justify-center items-center {get_selectable_color_classes(
option.is_selected(),
{
bg: true,
hover: true,
active: true,
text: true
},
-100,
50
)}"
>
<span
class="h-7 w-3 rounded-full"
style="background-color: {option.color.color_string()};"
></span>
</button>
{/if}
</div>
{/each}
</div>
{/each}
</div>
<Button click_function={show_text} className="w-full font-bold">Text Anzeigen</Button>
</div>
</div>
<style>
:global(.tiptap) {
width: 100%;
height: 100%;
--tw-outline-style: none;
outline-style: none;
}
:global(.tiptap ul),
:global(.tiptap ol) {
padding-left: 1.5rem;
}
:global(.tiptap ul) {
list-style: disc;
}
:global(.tiptap ol) {
list-style: decimal;
}
:global(.tiptap p) {
margin-bottom: 0.5rem;
}
:global(.tiptap p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
pointer-events: none;
color: oklch(44.4% 0.011 73.639);
float: left;
height: 0;
opacity: 0.7;
}
@@ -1,47 +0,0 @@
<script>
import { Minus, Settings, TvMinimalPlay } from 'lucide-svelte';
import Button from '../../components/Button.svelte';
import { pinned_display_id } from '../../ts/stores/ui_behavior';
import { fade, slide } from 'svelte/transition';
</script>
<main class="bg-stone-900 h-dvh w-dvw text-stone-200 p-4 gap-4 grid grid-rows-[3rem_auto]">
<div class="w-[calc(100dvw-(8*var(--spacing)))] flex justify-between">
<span class="text-4xl font-bold content-center h-full"> PLG MuDiCS </span>
<Button className="aspect-square" bg="bg-stone-800">
<Settings></Settings>
</Button>
</div>
<div class="w-[calc(100dvw-(8*var(--spacing)))] grid grid-cols-2 gap-2">
<div class="h-[calc(100dvh-3rem-(12*var(--spacing)))] flex flex-col gap-2"></div>
<div
class="col-start-2 h-[calc(100dvh-3rem-(12*var(--spacing)))] rounded-2xl flex flex-col gap-2"
>
<div class="bg-stone-800 h-full rounded-2xl grid grid-rows-[2.5rem_auto]">
<div class="bg-stone-700 flex justify-between w-full p-1 rounded-t-2xl min-w-0 gap-2">
<span class="text-xl font-bold pl-2 content-center truncate min-w-0">
Dateien anzeigen und verwalten
</span>
<div class="flex flex-ro"></div>
</div>
<div class="flex flex-col gap-2 p-2 max-w-full overflow-hidden min-w-0">
<div class="flex flex-row justify-between gap-4 min-w-0">
<div class="flex flex-row gap-2 min-w-0 flex-1">
<Button className="px-3 flex items-center gap-3 min-w-0 flex-1 justify-start">
<TvMinimalPlay />
<!-- Text: nimmt Restbreite, darf schrumpfen & ellipsieren -->
<span class="truncate min-w-0 flex-1">Sehr lange Beschriftung für den Button</span>
</Button>
<div class="relative min-w-0 flex">
<button class="text-xl font-bold pl-2 content-center truncate min-w-0">
Dateien anzeigen und verwalten
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
+6 -2
View File
@@ -201,6 +201,7 @@ module.exports = {
'group-active:border-stone-900',
'group-active:border-stone-950',
'h-10',
'h-15',
'h-20',
'h-25',
@@ -213,5 +214,8 @@ module.exports = {
'hover:bg-red-400',
'active:bg-red-500',
],
}
'hover:bg-stone-600/70',
'active:bg-stone-500/80'
]
};
-83
View File
@@ -1,83 +0,0 @@
import type { FolderElement } from "./types";
import { get_uuid } from "./utils";
export async function get_screenshot(ip: string) {
const options = { method: 'PATCH' };
return await request(ip, '/takeScreenshot', options);
}
export async function open_file(ip: string, path_to_file: string) {
const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } };
const raw_response = await request(ip, `/file${path_to_file}`, options);
}
export async function get_file_data(ip: string, path: string): Promise<FolderElement[]> {
interface FileInfo {
name: string;
type: string;
size: string;
created: string;
}
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do
typ=$(file -b --mime-type -- "$f")
size=$(stat -c '%s' -- "$f")
created=$(stat -c '%w' -- "$f")
[ "$created" = "-" ] && created=$(stat -c '%y' -- "$f")
jq -n --arg name "$f" --arg type "$typ" --arg size "$size" --arg created "$created" \
'{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n'
echo
done
` })
};
const raw_response = await request(ip, '/shellCommand', options);
const response: FileInfo[] = raw_response.stdout.trim()
.split("\n")
.filter(Boolean)
.map((line: string) => JSON.parse(line) as FileInfo);
const folder_element_list: FolderElement[] = [];
for (const response_element of response) {
// filter hidden files (start with '.' -> './.config')
if (response_element.name.charAt(2) !== '.') {
const folder_element: FolderElement = {
id: get_uuid(),
hash: JSON.stringify(response),
thumbnail_url: null,
name: response_element.name.slice(2), // remove "./"
type: response_element.type,
date_created: new Date(response_element.created),
size: Number(response_element.size),
};
folder_element_list.push(folder_element);
}
}
return folder_element_list;
}
async function request(ip: string, api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }) {
try {
const url = `http://${ip}:1323/api${api_route}?t=${Date.now()}`;
console.log(url)
const response = await fetch(url, options);
if (!response.ok) {
console.error(`HTTP error! Status: ${response.status}`);
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
return await response.blob();
} else {
return await response.json();
}
} catch (error) {
console.error(error);
}
}
-4
View File
@@ -1,4 +0,0 @@
export function on_start() {
}
-169
View File
@@ -1,169 +0,0 @@
import { get, writable, type Writable } from "svelte/store";
import type { Display, DisplayGroup } from "../types";
import { is_selected, select, selected_display_ids } from "./select";
import { get_uuid, image_content_hash } from "../utils";
import { get_screenshot } from "../api_handler";
export const displays: Writable<DisplayGroup[]> = writable<DisplayGroup[]>([{
id: get_uuid(),
data: []
}]);
export function is_display_name_taken(name: string): boolean {
const display_groups = get(displays);
return display_groups.some(group =>
group.data.some(display => display.name.trim().toLowerCase() === name.trim().toLowerCase())
);
}
export function add_display(ip: string, mac: string | null, name: string, status: string) {
displays.update((displays: DisplayGroup[]) => {
displays[0].data.push({ id: get_uuid(), ip, preview_url: null, preview_timeout_id: null, mac, name, status });
return displays;
});
}
export function edit_display_data(display_id: string, ip: string, mac: string | null, name: string) {
displays.update((display_groups) =>
display_groups.map((group) => ({
...group,
data: group.data.map((display) => {
if (display.id !== display_id) return display;
return { ...display, ip: ip, mac: mac, name: name };
}),
}))
);
}
export function remove_display(display_id: string) {
console.log(display_id);
displays.update((displays: DisplayGroup[]) => {
displays = displays.map(display_group => ({
...display_group,
data: display_group.data.filter(display => display.id !== display_id)
}));
return displays;
});
// TODO remove ID from Files usw.
}
export function all_displays_of_group_selected(display_group: DisplayGroup, current_selected_displays: string[]) {
if (display_group.data.length === 0) return false;
for (const display of display_group.data) {
if (!is_selected(display.id, current_selected_displays)) {
return false;
}
}
return true;
}
export function select_all_of_group(display_group: DisplayGroup, new_value: boolean | null = null) {
for (const display of display_group.data) {
select(selected_display_ids, display.id, new_value);
}
}
export function set_new_display_group_data(display_group_id: string, new_data: Display[]) {
displays.update((displays: DisplayGroup[]) => {
for (const display_group of displays) {
if (display_group.id === display_group_id) {
display_group.data = new_data;
}
}
return displays;
});
}
export function get_display_by_id(display_id: string, display_group_array: DisplayGroup[]) {
const displays_array = display_group_array;
for (const display_group of displays_array) {
for (const display of display_group.data) {
if (display.id === display_id) {
return display;
}
}
}
return null;
}
export function add_empty_display_group() {
displays.update((displays: DisplayGroup[]) => {
displays.push({
id: get_uuid(),
data: [],
});
return displays;
});
}
export function remove_empty_display_groups() {
displays.update((displays: DisplayGroup[]) => {
for (let i = displays.length - 1; i >= 0; i--) {
if (displays[i].data.length === 0) {
displays.splice(i, 1);
}
}
return displays;
});
}
export async function update_screenshot(display_id: string, check_type: "first_check" | "last_check_different" | "last_check_same" = "first_check") {
const display_ip = get_display_by_id(display_id, get(displays))?.ip;
if (!display_ip) return;
const new_blob = await get_screenshot(display_ip);
const display = get_display_by_id(display_id, get(displays));
let update_needed = check_type === "first_check";
if (check_type !== "first_check") {
if (display && display.preview_url) {
const old_blob = await fetch(display.preview_url).then(r => r.blob());
const new_hash = await image_content_hash(new_blob);
const old_hash = await image_content_hash(old_blob);
console.log(old_hash, new_hash);
update_needed = old_hash !== new_hash; // if different -> update
}
}
let new_preview_timeout_id: number | null = null;
if (update_needed || check_type === "last_check_different") {
new_preview_timeout_id = setTimeout(async () => { await update_screenshot(display_id, update_needed ? "last_check_different" : "last_check_same") }, 2 * 1000);
}
if (display?.preview_timeout_id) {
clearInterval(display.preview_timeout_id);
}
if (update_needed) {
displays.update((display_groups) =>
display_groups.map((group) => ({
...group,
data: group.data.map((display) => {
if (display.id !== display_id) return display;
if (display.preview_url) {
URL.revokeObjectURL(display.preview_url);
}
const new_url = URL.createObjectURL(new_blob);
return { ...display, preview_url: new_url, preview_timeout_id: new_preview_timeout_id };
}),
}))
);
}
}
add_testing_displays();
function add_testing_displays() {
// const names = ["Vorne Rechts", "Vorne Links", "Vorne Mitte", "Fernseher Rechts", "Fernseher Bühne", "UIUIUIUIUIUIUISEHRLANGERTEXT DER IST WIRKLICH LANG, DER TEXT, so lang, dass er wirklich nirgendswo hinpasst, nichtmal da oben /\\"];
// for (const name of names) {
// add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", name, "Offline");
// }
add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", "PC", "Offline");
// add_display("192.168.178.111", "D4:81:D7:C0:DF:3C", "Laptop", "Online");
}
-242
View File
@@ -1,242 +0,0 @@
import { get, writable, type Writable } from "svelte/store";
import type { FolderElement } from "../types";
import { displays } from "./displays";
import { selected_display_ids, selected_file_ids } from "./select";
import { get_file_data } from "../api_handler";
import { get_uuid } from "../utils";
export const all_files: Writable<Record<string, Record<string, FolderElement[]>>> = writable<Record<string, Record<string, FolderElement[]>>>({});
// {
// path: {
// display_id: FolderElement[]
// ...
// },
// path2: {
// display_id: FolderElement[]
// ...
// },
// ...
// }
export const current_file_path: Writable<string> = writable<string>('/');
export function change_file_path(new_path: string) {
current_file_path.update(() => {
return new_path;
});
selected_file_ids.update(() => {
return [];
})
}
export function get_display_ids_where_file_is_missing(path: string, file: FolderElement, selected_display_ids: string[], all_files: Record<string, Record<string, FolderElement[]>>): string[][] {
if (!all_files.hasOwnProperty(path)) return [];
const missing: string[] = [];
const colliding: string[] = [];
Display:
for (const selected_display_id of selected_display_ids) {
if (!all_files[path].hasOwnProperty(selected_display_id)) {
missing.push(selected_display_id);
continue;
}
for (const folder_element of all_files[path][selected_display_id]) {
if (folder_element.name === file.name) {
if (folder_element.hash !== file.hash) {
colliding.push(selected_display_id);
}
continue Display;
}
}
missing.push(selected_display_id);
}
return [missing, colliding];
}
export function updates_files_on_display(display_id: string, new_folder_elements: FolderElement[], file_path: string) {
all_files.update((files) => {
if (!files.hasOwnProperty(file_path)) {
files[file_path] = {};
}
for (const new_folder_element of new_folder_elements) {
new_folder_element.id = get_uuid();
}
files[file_path][display_id] = new_folder_elements;
return files;
});
}
async function get_files_on_all_displays(current_file_path: string) {
for (const display_group of get(displays)) {
for (const display of display_group.data) {
const new_folder_elements = await get_file_data(display.ip, current_file_path);
updates_files_on_display(display.id, new_folder_elements, current_file_path)
console.log(new_folder_elements)
}
}
}
export function get_current_folder_elements(all_files: Record<string, Record<string, FolderElement[]>>, current_file_path: string, selected_display_ids: string[]) {
if (!all_files.hasOwnProperty(current_file_path)) {
setTimeout(async () => { await get_files_on_all_displays(current_file_path) }, 0);
return [];
}
const files_on_display_array = all_files[current_file_path];
const files: FolderElement[] = [];
for (const key of Object.keys(files_on_display_array)) {
if (selected_display_ids.includes(key)) {
FileOnDisplay:
for (const file_on_display of files_on_display_array[key]) {
for (const existing_file of files) {
if (file_on_display.name === existing_file.name) {
if (file_on_display.hash === existing_file.hash) {
continue FileOnDisplay;
}
}
}
files.push(file_on_display);
}
}
}
return sort_files(files);
}
function sort_files(files: FolderElement[]) {
files.sort((a, b) => {
const isDirA = a.type === 'inode/directory';
const isDirB = b.type === 'inode/directory';
// Ordner zuerst
if (isDirA && !isDirB) return -1;
if (!isDirA && isDirB) return 1;
// Danach alphabetisch nach name (case-insensitive)
const nameCompare = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
if (nameCompare !== 0) return nameCompare;
// Wenn name gleich, absteigend nach date_created
return b.date_created.getTime() - a.date_created.getTime();
});
return files;
}
add_test_files();
export function add_test_files() {
// updates_files_on_display(get(displays)[0].data[0].id, [
// {
// hash: "ok",
// thumbnail: null,
// name: "ok.png",
// type: "image/png",
// date_created: new Date("2022-09-10"),
// size: "32 KB"
// },
// {
// hash: "schön",
// thumbnail: null,
// name: "schön.png",
// type: "image/png",
// date_created: new Date("2023-09-10"),
// size: "2 GB"
// },
// {
// hash: "lang",
// thumbnail: null,
// name: "langer Bildname der wirklich gar nicht mehr aufhört, sodass man mal gut testen kann, wie die UI darauf reagiert.odp",
// type: "application/vnd.oasis.opendocument.presentation",
// date_created: new Date("2028-09-10"),
// size: "324 KB"
// },
// {
// hash: "Schön hier aber waren Sie mal in Baden Würtemberg?",
// thumbnail: null,
// name: "Schön hier aber waren Sie mal in Baden Würtemberg?.mp4",
// type: "video/mp4",
// date_created: new Date("2024-09-10"),
// size: "32 KB"
// },
// {
// hash: "lan4234g",
// thumbnail: null,
// name: "Ein schöner Ordner",
// type: 'inode/directory',
// date_created: new Date("2025-01-02"),
// size: "324 TB"
// },
// {
// hash: "Schön hi23424er aber waren Sie mal in Baden Würtemberg?",
// thumbnail: null,
// name: "Ein hässlicher Ordner",
// type: 'inode/directory',
// date_created: new Date("2025-10-22"),
// size: "1 B"
// },
// ], '/');
// updates_files_on_display(get(displays)[0].data[0].id, [
// {
// hash: "ok",
// thumbnail: null,
// name: "ok.png",
// type: "image/png",
// date_created: new Date("2022-09-10"),
// size: "32 KB"
// },
// {
// hash: "schön",
// thumbnail: null,
// name: "schön.png",
// type: "image/png",
// date_created: new Date("2023-09-10"),
// size: "2 GB"
// },
// ], '/Ein hässlicher Ordner/');
// updates_files_on_display(get(displays)[0].data[0].id, [
// {
// hash: "nö",
// thumbnail: null,
// name: "nö.png",
// type: "image/png",
// date_created: new Date("2022-09-10"),
// size: "32 KB"
// },
// {
// hash: "na gut",
// thumbnail: null,
// name: "na gut.png",
// type: "image/png",
// date_created: new Date("2023-09-10"),
// size: "2 GB"
// },
// ], '/Ein schöner Ordner/');
// updates_files_on_display(get(displays)[0].data[1].id, [
// {
// hash: "okk",
// thumbnail: null,
// name: "ok.png",
// type: "image/png",
// date_created: new Date("2022-09-10"),
// size: "32 KB"
// },
// {
// hash: "schön",
// thumbnail: null,
// name: "schön.png",
// type: "image/png",
// date_created: new Date("2023-09-10"),
// size: "2 GB"
// },], '/');
}
-23
View File
@@ -1,23 +0,0 @@
import { writable, type Writable } from "svelte/store";
export const selected_file_ids: Writable<string[]> = writable<string[]>([]);
export const selected_display_ids: Writable<string[]> = writable<string[]>([]);
export function select(selected_ids: Writable<string[]>, id: string, new_value: boolean | null = null) {
selected_ids.update((all_ids: string[]) => {
if (all_ids.includes(id)) {
const index = all_ids.indexOf(id);
if (index > -1 && new_value !== true) {
all_ids.splice(index, 1);
}
} else if (new_value !== false) {
all_ids.push(id);
}
return all_ids;
});
}
export function is_selected(id: string, selected_ids: string[]): boolean {
return selected_ids.includes(id);
}
@@ -1,72 +0,0 @@
import { writable, type Writable } from "svelte/store";
export const dnd_flip_duration_ms = 300;
const heights_options: Record<string, { min: number; max: number; step: number }> = {
display: {
min: 15,
max: 40,
step: 5,
},
file: {
min: 10,
max: 20,
step: 5,
}
}
export const current_height: Writable<Record<string, number>> = writable<Record<string, number>>({
display: 25,
file: 10,
});
export const is_group_drag: Writable<boolean> = writable<boolean>(false);
export const is_display_drag: Writable<boolean> = writable<boolean>(false);
export const pinned_display_id: Writable<string | null> = writable<string | null>(null);
export function change_height(key: 'display' | 'file', factor: number) {
current_height.update((height) => {
height[key] = next_height_step_size(key, height, factor) || height[key];
return height;
});
}
export function next_height_step_size(key: 'display' | 'file', current_height_array: Record<string, number>, factor: number): number {
const new_size = current_height_array[key] + (factor * heights_options[key].step);
if (new_size > heights_options[key].max || new_size < heights_options[key].min) {
return 0;
} else {
return new_size;
}
}
export function get_selectable_color_classes(
selected: boolean,
returning_classes: { bg?: boolean; hover?: boolean; active?: boolean; text?: boolean } = {},
base_bg_distance: number = 0,
shifted_distance: number = 0
) {
let base_bg = selected ? 'bg-stone-400' : 'bg-stone-600';
const base_text = selected ? 'text-stone-950' : 'text-stone-200';
base_bg = get_shifted_color(base_bg, base_bg_distance);
const { bg = false, hover = false, active = false, text = false } = returning_classes;
const out: string[] = [];
if (bg) out.push(base_bg);
if (hover) out.push('hover:' + get_shifted_color(base_bg, 100 + shifted_distance));
if (active) out.push('active:' + get_shifted_color(base_bg, 150 + shifted_distance));
if (text) out.push(base_text);
return out.join(' ');
}
export function get_shifted_color(base_color: string, distance: number): string {
return base_color.replace(/(\d+)(?=(?:\/\d+)?$)/, (m: string) => String(+Number(m) - distance));
}
-91
View File
@@ -1,91 +0,0 @@
import { FileBox, FileImage, FileVideoCamera, ImagePlay, type X } from "lucide-svelte";
import type { Snippet } from "svelte";
export type SupportedFileType = {
display_name:
string; mime_type:
string; icon: typeof X;
};
export const supported_file_types: Record<string, SupportedFileType> = {
'.mp4': {
display_name: 'MP4',
mime_type: 'video/mp4',
icon: FileVideoCamera,
},
'.jpg': {
display_name: 'JPG',
mime_type: 'image/jpg',
icon: FileImage,
},
'.jpeg': {
display_name: 'JPG',
mime_type: 'image/jpeg',
icon: FileImage,
},
'.png': {
display_name: 'PNG',
mime_type: 'image/png',
icon: FileImage,
},
'.gif': {
display_name: 'GIF',
mime_type: 'image/gif',
icon: ImagePlay,
},
'.pptx': {
display_name: 'PPTX',
mime_type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
icon: FileBox,
},
'.odp': {
display_name: 'ODP',
mime_type: 'application/vnd.oasis.opendocument.presentation',
icon: FileBox,
}
}
export type FolderElement = {
id?: string;
hash: string | null;
thumbnail_url: string | null;
name: string;
type: string;
date_created: Date;
size: number;
}
export type Display = {
id: string;
ip: string;
preview_url: string | null;
preview_timeout_id: number | null;
mac: string | null;
name: string;
status: string;
}
export type DisplayGroup = {
id: string;
data: Display[];
};
export type MenuOption = {
icon?: typeof X;
name: string;
class?: string;
on_select?: () => void;
disabled?: boolean;
}
export type PopupContent = {
open: boolean;
snippet: Snippet<[string]> | null;
snippet_arg?: string;
title: string;
title_class?: string;
title_icon?: typeof X | null;
closable?: boolean;
}
-42
View File
@@ -1,42 +0,0 @@
export function get_uuid(): string {
return crypto.randomUUID();
}
export function get_file_size_display_string(size: number, toFixed: number|null = null): string {
if (size === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(size) / Math.log(k));
const value = size / Math.pow(k, i);
const size_string = `${value.toFixed(toFixed !== null ? toFixed : Math.max(0, 2 - Math.floor(Math.log10(value))))} ${sizes[i]}`;
return size_string.replace('.', ',');
}
export async function image_content_hash(blob: Blob, size = 32): Promise<number> {
// Blob → ImageBitmap (GPU-dekodiert, superschnell)
const bitmap = await createImageBitmap(blob);
// OffscreenCanvas ist sehr schnell (kein Layout nötig)
const canvas = new OffscreenCanvas(size, size);
const ctx = canvas.getContext("2d")!;
ctx.drawImage(bitmap, 0, 0, size, size);
// Pixel-Daten holen
const { data } = ctx.getImageData(0, 0, size, size);
// Einfacher, schneller Integer-Hash (FNV-1a)
let hash = 2166136261;
for (let i = 0; i < data.length; i++) {
hash ^= data[i];
hash = Math.imul(hash, 16777619);
}
bitmap.close(); // GPU-Ressourcen freigeben
return hash >>> 0; // unsigned int
}
+12 -6
View File
@@ -3,18 +3,24 @@ module plg-mudics/control
go 1.24.5
require (
github.com/labstack/echo/v4 v4.13.4
github.com/labstack/echo/v4 v4.15.0
github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a
)
require (
github.com/josharian/native v1.0.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/ethernet v0.0.0-20190313224307-5b5fc417d966 // indirect
github.com/mdlayher/packet v1.0.0 // indirect
github.com/mdlayher/socket v0.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
)
+47
View File
@@ -1,23 +1,70 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/ethernet v0.0.0-20190313224307-5b5fc417d966 h1:O3p5UmisBhl3V6lgs4Vdfg8HpjzbWJPyOfGLdwVJSmI=
github.com/mdlayher/ethernet v0.0.0-20190313224307-5b5fc417d966/go.mod h1:5s5p/sMJ6sNsFl6uCh85lkFGV8kLuIYJCRJLavVJwvg=
github.com/mdlayher/packet v1.0.0 h1:InhZJbdShQYt6XV2GPj5XHxChzOfhJJOMbvnGAmOfQ8=
github.com/mdlayher/packet v1.0.0/go.mod h1:eE7/ctqDhoiRhQ44ko5JZU2zxB88g+JH/6jmnjzPjOU=
github.com/mdlayher/raw v0.0.0-20190313224157-43dbcdd7739d/go.mod h1:r1fbeITl2xL/zLbVnNHFyOzQJTgr/3fpf1lJX/cjzR8=
github.com/mdlayher/socket v0.2.1 h1:F2aaOwb53VsBE+ebRS9bLd7yPOfYUMC8lOODdCBDY6w=
github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E=
github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a h1:fpQi42PM6HzbHwNg6a7OvLbh5l49Rgr7GCARozjHwio=
github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a/go.mod h1:XX6nvyQpGc8E4KaQm62FyexOYd36ilpyfYf8wjgMDV8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+67 -4
View File
@@ -4,18 +4,42 @@ import (
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"plg-mudics/control/frontend"
"plg-mudics/shared"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/mdlayher/wol"
)
var storageFile string
func main() {
var err error
path, err := getStoragePath()
if err != nil {
slog.Error("Failed to initialize storage path", "error", err)
os.Exit(1)
}
storageFile = filepath.Join(path, "storage.json")
// Ensure storage.json exists
if _, err := os.Stat(storageFile); os.IsNotExist(err) {
if err := os.WriteFile(storageFile, []byte("{}"), 0644); err != nil {
slog.Error("Failed to initialize storage.json", "error", err)
os.Exit(1)
}
}
e := echo.New()
// Allow requests from everywhere
e.Use(middleware.CORS())
// Serve the embedded SvelteKit frontend
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Filesystem: http.FS(frontend.BuildDirFS),
@@ -25,14 +49,25 @@ func main() {
// Servers all API endpoints, e.g. our custom logic
apiGroup := e.Group("/api")
apiGroup.GET("/ping", pingRoute)
apiGroup.POST("/wakeOnLan", wakeOnLanRoute)
apiGroup.GET("/storage", getStorageRoute)
apiGroup.POST("/storage", setStorageRoute)
port := "8080"
go shared.OpenBrowserWindow("http://localhost:"+port, false, false)
// the order is important, the open browser command exitsts as soon as the winodw is closed
// and since its the last action in the main go func all other goroutines (e.g. the webserver) are killed
go func() {
err := e.Start(":" + port)
if err != nil {
slog.Error("Failed to start Echo Webserver", "error", err)
os.Exit(1)
}
}()
err = shared.OpenBrowserWindow("http://localhost:"+port, false, "control")
if err != nil {
slog.Error("Failed to open browser window", "error", err)
os.Exit(1)
}
}
@@ -42,13 +77,13 @@ func pingRoute(ctx echo.Context) error {
return ctx.JSON(http.StatusBadRequest, PingResponse{Error: "missing 'ip' query parameter"})
}
cmd := exec.Command("ping", "-c", "1", "-w", "1", ip)
cmd := exec.Command("ping", "-c", "1", "-w", "5", ip)
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
return ctx.JSON(http.StatusOK, PingResponse{Status: "host_offline"})
}
conn, err := net.DialTimeout("tcp", ip+":1323", 1*time.Second)
conn, err := net.DialTimeout("tcp", ip+":1323", 5*time.Second)
if err != nil {
return ctx.JSON(http.StatusOK, PingResponse{Status: "app_offline"})
}
@@ -61,3 +96,31 @@ type PingResponse struct {
Status string `json:"status"`
Error string `json:"error"`
}
type WakeOnLanRequest struct {
MACAddress string `json:"mac_address"`
}
func wakeOnLanRoute(ctx echo.Context) error {
var data WakeOnLanRequest
if err := ctx.Bind(&data); err != nil {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
}
mac, err := net.ParseMAC(data.MACAddress)
if err != nil {
slog.Warn("Invalid MAC address provided", "mac_address", data.MACAddress, "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid MAC address"})
}
client, err := wol.NewClient()
if err != nil {
slog.Error("Failed to create Wake-on-LAN client", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to create Wake-on-LAN client"})
}
if err := client.Wake("255.255.255.255:7", mac); err != nil {
slog.Error("Failed to send Wake-on-LAN packet", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to send Wake-on-LAN packet"})
}
return ctx.JSON(http.StatusOK, struct{}{})
}
+16
View File
@@ -0,0 +1,16 @@
meta {
name: Get Store
type: http
seq: 2
}
get {
url: http://localhost:8080/api/storage
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}
+22
View File
@@ -0,0 +1,22 @@
meta {
name: Set Store
type: http
seq: 3
}
post {
url: http://localhost:8080/api/storage
body: json
auth: inherit
}
body:json {
{
"key": "value"
}
}
settings {
encodeUrl: true
timeout: 0
}
+22
View File
@@ -0,0 +1,22 @@
meta {
name: wakeOnLan
type: http
seq: 4
}
post {
url: http://localhost:8080/api/wakeOnLan
body: json
auth: inherit
}
body:json {
{
"mac_address": "aa:aa:aa:aa:aa:aa"
}
}
settings {
encodeUrl: true
timeout: 0
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"plg-mudics/shared"
"github.com/labstack/echo/v4"
)
func getStorageRoute(ctx echo.Context) error {
data, err := os.ReadFile(storageFile)
if err != nil {
slog.Error("Could not read storage file", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Could not read storage file"})
}
var content interface{}
if err := json.Unmarshal(data, &content); err != nil {
slog.Error("Could not parse storage file", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Could not parse storage file"})
}
return ctx.JSON(http.StatusOK, content)
}
func setStorageRoute(ctx echo.Context) error {
var payload interface{}
if err := ctx.Bind(&payload); err != nil {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid JSON payload"})
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to marshal storage file"})
}
if err := os.WriteFile(storageFile, data, 0644); err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to write storage file"})
}
return ctx.JSON(http.StatusOK, map[string]interface{}{})
}
func getStoragePath() (string, error) {
var storagePath string
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("unable to determine user home directory: %w", err)
}
storagePath = filepath.Join(home, ".local", "share", "plg-mudics", "control")
if err := os.MkdirAll(storagePath, os.ModePerm); err != nil {
return "", fmt.Errorf("failed to create local config directory: %w", err)
}
return storagePath, nil
}
+99 -12
View File
@@ -1,16 +1,103 @@
# PLG-MuDiCS Display
# API
## Runtime Requirements
All response and request bodies are `application/json` if not otherwise specified. If no response schema is specified an empty json object `{}` is returned.
- Bash
- Chromium
- LibreOffice (presentation view)
- ImageMagick (file preview)
- ffmpeg (file preview)
- Ghostscript (file preview)
## Default
## Development Setup
### Responses
- install Go (at least version v1.25)
- build template: `go generate ./..`
- run code: `go run .`
#### All Errors
If not specified otherwise.
- `description`: string
#### 400
Request uses invalid JSON syntax or does not follow request schema.
#### 500
Something (undefined) on the display side has gone very wrong.
## GET `/ping`
### Responses
#### 200
- `version`: str
## PATCH `/shellCommand`
### Responses
#### 200
Even when the command itself fails.
- `stdout`: string
- `stderr`: string
- `exitCode`: int
## PATCH `/keyboardInput`
### Request Body
- `inputs`: list
- `key`: string for key
- `action`: "press" or "release"
## PATCH `/showHTML`
### Request Body
- `html`: string
## PATCH `/takeScreenshot`
### Responses
#### 200
The screenshot as binary in the response body.
## POST `/file/<path>` - Upload File
### Responses
#### 409 - Conflict
File with the same path and name already exists.
## GET `/file/<path>`
### Responses
#### 404
Requested file was not found at the path.
## PATCH `/file/<path>` - Open File
### Responses
#### 404
Requested file was not found at the path.
#### 415 - Unsupported Media Type
The type of the file is not available for display.
## GET `/file/preview/<path>`
### Responses
#### 404
Requested file was not found at the path.
#### 415 - Unsupported Media Type
The type of the file is not available preview generation.
+12 -9
View File
@@ -3,13 +3,15 @@ module plg-mudics/display
go 1.24.4
require (
github.com/labstack/echo/v4 v4.13.4
github.com/a-h/templ v0.3.960
github.com/gabriel-vasile/mimetype v1.4.11
github.com/labstack/echo/v4 v4.15.0
github.com/micmonay/keybd_event v1.1.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
)
require (
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/a-h/templ v0.3.924 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
@@ -21,13 +23,14 @@ require (
github.com/natefinch/atomic v1.0.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.32.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.39.0 // indirect
)
tool github.com/a-h/templ/cmd/templ
+43 -16
View File
@@ -1,7 +1,7 @@
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.924 h1:t5gZqTneXqvehpNZsgtnlOscnBboNh9aASBH2MgV/0k=
github.com/a-h/templ v0.3.924/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334=
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -14,8 +14,14 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -28,26 +34,47 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+4 -4
View File
@@ -9,8 +9,6 @@ import (
"plg-mudics/shared"
)
const VERSION = "0.1.0"
//go:generate go tool templ generate
func main() {
@@ -24,9 +22,11 @@ func main() {
return
}
port := "1323"
go web.StartWebServer(VERSION, port)
err = shared.OpenBrowserWindow("http://localhost:"+port, true, true)
// the order is important, the open browser command exitsts as soon as the winodw is closed
// and since its the last action in the main go func all other goroutines (e.g. the webserver) are killed
go web.StartWebServer(shared.Version, port)
err = shared.OpenBrowserWindow("http://localhost:"+port, true, "display")
if err != nil {
slog.Error("Failed to open browser window", "error", err)
os.Exit(1)
+75
View File
@@ -0,0 +1,75 @@
package pkg
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"plg-mudics/shared"
"syscall"
"github.com/gabriel-vasile/mimetype"
)
var FileHandler fileHandler = fileHandler{}
type fileHandler struct {
runningProgram *exec.Cmd
}
func (fh *fileHandler) OpenFile(path string) error {
var err error
mType, err := mimetype.DetectFile(path)
if err != nil {
return fmt.Errorf("failed to detect mime type: %w", err)
}
tempDirPath, err := os.MkdirTemp("", "plg-mudics-program-profile-")
if err != nil {
return fmt.Errorf("failed to create temporary profile directory: %w", err)
}
err = fh.CloseRunningProgram()
if err != nil {
return err
}
switch mType.String() {
case "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation":
// yes, we need this weird workaround to delete lock files since libreoffice
// doesn't expose an option to ignore them or prevent their creation
// the --view argument for some reason doesn't work with --show
parent := filepath.Dir(path)
cmd := exec.Command("find", parent, "-name", ".~lock*", "-type", "f", "-delete")
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
slog.Warn("could not remove lock files", "path", parent, "stderr", result.Stderr, "exitCode", result.ExitCode)
}
fh.runningProgram = exec.Command("soffice", "--show", path, "--nologo", "--norestore", fmt.Sprintf("-env:UserInstallation=file://%s", tempDirPath))
case "application/pdf":
fh.runningProgram = exec.Command("xreader", path, "--presentation")
default:
return fmt.Errorf("unsupported file type: %s", mType.String())
}
fh.runningProgram.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
result := shared.RunShellCommandNonBlocking(fh.runningProgram)
if result.ExitCode != 0 {
return fmt.Errorf("could not open pdf: %s (%d)", result.Stderr, result.ExitCode)
}
return nil
}
func (fh *fileHandler) CloseRunningProgram() error {
if fh.runningProgram == nil {
return nil
}
err := syscall.Kill(-fh.runningProgram.Process.Pid, syscall.SIGTERM)
fh.runningProgram = nil
return err
}
+5 -4
View File
@@ -10,8 +10,9 @@ import (
"strings"
"time"
)
var ErrFileTypePreviewNotSupported = errors.New("file type not supported for preview");
var ErrFilePreviewToolsMissing = errors.New("required tools for file preview are missing");
var ErrFileTypePreviewNotSupported = errors.New("file type not supported for preview")
var ErrFilePreviewToolsMissing = errors.New("required tools for file preview are missing")
func GenerateFilePreview(inputPath string) (string, error) {
var err error
@@ -36,7 +37,7 @@ func GenerateFilePreview(inputPath string) (string, error) {
}
func generateImagePreview(inputPath string, outputPath string) error {
cmd := exec.Command("magick", inputPath, "-thumbnail", "100x100", "-quality", "50", outputPath)
cmd := exec.Command("magick", inputPath, "-gravity", "center", "-crop", "1:1", "-thumbnail", "100", "-quality", "50", outputPath)
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
if result.ExitCode == 127 {
@@ -53,7 +54,7 @@ func generatePDFPreview(inputPath string, outputPath string) error {
return ErrFilePreviewToolsMissing
}
cmd := exec.Command("magick", fmt.Sprintf("%s[0]", inputPath), "-thumbnail", "100x100", "-quality", "50", outputPath)
cmd := exec.Command("magick", fmt.Sprintf("%s[0]", inputPath), "-gravity", "center", "-crop", "1:1", "-thumbnail", "100x100", "-quality", "50", outputPath)
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
if result.ExitCode == 127 {
+178
View File
@@ -0,0 +1,178 @@
package pkg
import (
"fmt"
"log/slog"
"time"
"github.com/micmonay/keybd_event"
)
type KeyAction int
const (
KeyPress KeyAction = iota
KeyRelease
)
type Input struct {
Key string
Action KeyAction
}
func KeyboardInput(inputs []Input) error {
var err error
kb, err := keybd_event.NewKeyBonding()
if err != nil {
return fmt.Errorf("failed to create key bonding: %w", err)
}
for _, input := range inputs {
switch input.Key {
case "Shift", "ShiftLeft", "ShiftRight":
kb.HasSHIFT(true)
case "Ctrl", "Control", "ControlLeft", "ControlRight":
kb.HasCTRL(true)
case "Alt", "AltLeft", "AltRight":
kb.HasALT(true)
case "Super", "Meta", "MetaLeft", "MetaRight":
kb.HasSuper(true)
default:
keyCode, ok := KeyboardEvents[input.Key]
if !ok {
slog.Warn("Could not parse keyboard input", "key", input.Key)
continue
}
kb.SetKeys(keyCode)
}
switch input.Action {
case KeyPress:
err = kb.Press()
case KeyRelease:
err = kb.Release()
}
if err != nil {
return fmt.Errorf("failed to run key event: %w", err)
}
time.Sleep(time.Microsecond * 10)
}
return nil
}
var KeyboardEvents = map[string]int{
"Escape": keybd_event.VK_ESC,
"Digit1": keybd_event.VK_1,
"Digit2": keybd_event.VK_2,
"Digit3": keybd_event.VK_3,
"Digit4": keybd_event.VK_4,
"Digit5": keybd_event.VK_5,
"Digit6": keybd_event.VK_6,
"Digit7": keybd_event.VK_7,
"Digit8": keybd_event.VK_8,
"Digit9": keybd_event.VK_9,
"Digit0": keybd_event.VK_0,
"KeyQ": keybd_event.VK_Q,
"KeyW": keybd_event.VK_W,
"KeyE": keybd_event.VK_E,
"KeyR": keybd_event.VK_R,
"KeyT": keybd_event.VK_T,
"KeyY": keybd_event.VK_Y,
"KeyU": keybd_event.VK_U,
"KeyI": keybd_event.VK_I,
"KeyO": keybd_event.VK_O,
"KeyP": keybd_event.VK_P,
"KeyA": keybd_event.VK_A,
"KeyS": keybd_event.VK_S,
"KeyD": keybd_event.VK_D,
"KeyF": keybd_event.VK_F,
"KeyG": keybd_event.VK_G,
"KeyH": keybd_event.VK_H,
"KeyJ": keybd_event.VK_J,
"KeyK": keybd_event.VK_K,
"KeyL": keybd_event.VK_L,
"KeyZ": keybd_event.VK_Z,
"KeyX": keybd_event.VK_X,
"KeyC": keybd_event.VK_C,
"KeyV": keybd_event.VK_V,
"KeyB": keybd_event.VK_B,
"KeyN": keybd_event.VK_N,
"KeyM": keybd_event.VK_M,
"F1": keybd_event.VK_F1,
"F2": keybd_event.VK_F2,
"F3": keybd_event.VK_F3,
"F4": keybd_event.VK_F4,
"F5": keybd_event.VK_F5,
"F6": keybd_event.VK_F6,
"F7": keybd_event.VK_F7,
"F8": keybd_event.VK_F8,
"F9": keybd_event.VK_F9,
"F10": keybd_event.VK_F10,
"F11": keybd_event.VK_F11,
"F12": keybd_event.VK_F12,
"F13": keybd_event.VK_F13,
"F14": keybd_event.VK_F14,
"F15": keybd_event.VK_F15,
"F16": keybd_event.VK_F16,
"F17": keybd_event.VK_F17,
"F18": keybd_event.VK_F18,
"F19": keybd_event.VK_F19,
"F20": keybd_event.VK_F20,
"F21": keybd_event.VK_F21,
"F22": keybd_event.VK_F22,
"F23": keybd_event.VK_F23,
"F24": keybd_event.VK_F24,
"NumLock": keybd_event.VK_NUMLOCK,
"ScrollLock": keybd_event.VK_SCROLLLOCK,
"CapsLock": keybd_event.VK_CAPSLOCK,
"Minus": keybd_event.VK_SP2,
"Equal": keybd_event.VK_SP3,
"Backspace": keybd_event.VK_BACKSPACE,
"Tab": keybd_event.VK_TAB,
"BracketLeft": keybd_event.VK_SP4,
"BracketRight": keybd_event.VK_SP5,
"Enter": keybd_event.VK_ENTER,
"Semicolon": keybd_event.VK_SP6,
"Quote": keybd_event.VK_SP7,
"Backquote": keybd_event.VK_SP1,
"Backslash": keybd_event.VK_SP8,
"Comma": keybd_event.VK_SP9,
"Period": keybd_event.VK_SP10,
"Slash": keybd_event.VK_SP11,
"IntlBackslash": keybd_event.VK_SP12,
"NumpadMultiply": keybd_event.VK_KPASTERISK,
"NumpadAdd": keybd_event.VK_KPPLUS,
"NumpadSubtract": keybd_event.VK_KPMINUS,
"NumpadDecimal": keybd_event.VK_KPDOT,
"Space": keybd_event.VK_SPACE,
"Numpad0": keybd_event.VK_KP0,
"Numpad1": keybd_event.VK_KP1,
"Numpad2": keybd_event.VK_KP2,
"Numpad3": keybd_event.VK_KP3,
"Numpad4": keybd_event.VK_KP4,
"Numpad5": keybd_event.VK_KP5,
"Numpad6": keybd_event.VK_KP6,
"Numpad7": keybd_event.VK_KP7,
"Numpad8": keybd_event.VK_KP8,
"Numpad9": keybd_event.VK_KP9,
"PageUp": keybd_event.VK_PAGEUP,
"PageDown": keybd_event.VK_PAGEDOWN,
"End": keybd_event.VK_END,
"Home": keybd_event.VK_HOME,
"ArrowLeft": keybd_event.VK_LEFT,
"ArrowUp": keybd_event.VK_UP,
"ArrowRight": keybd_event.VK_RIGHT,
"ArrowDown": keybd_event.VK_DOWN,
"PrintScreen": keybd_event.VK_PRINT,
"Insert": keybd_event.VK_INSERT,
"Delete": keybd_event.VK_DELETE,
"Help": keybd_event.VK_HELP,
"BrowserBack": keybd_event.VK_BACK,
"Pause": keybd_event.VK_PAUSE,
"Lang1": keybd_event.VK_HANGUEL,
"Lang2": keybd_event.VK_HANJA,
}
-129
View File
@@ -1,129 +0,0 @@
package pkg
import "github.com/micmonay/keybd_event"
var KeyboardEvents = map[string]int{
"VK_SP1": keybd_event.VK_SP1,
"VK_SP2": keybd_event.VK_SP2,
"VK_SP3": keybd_event.VK_SP3,
"VK_SP4": keybd_event.VK_SP4,
"VK_SP5": keybd_event.VK_SP5,
"VK_SP6": keybd_event.VK_SP6,
"VK_SP7": keybd_event.VK_SP7,
"VK_SP8": keybd_event.VK_SP8,
"VK_SP9": keybd_event.VK_SP9,
"VK_SP10": keybd_event.VK_SP10,
"VK_SP11": keybd_event.VK_SP11,
"VK_SP12": keybd_event.VK_SP12,
"VK_ESC": keybd_event.VK_ESC,
"VK_1": keybd_event.VK_1,
"VK_2": keybd_event.VK_2,
"VK_3": keybd_event.VK_3,
"VK_4": keybd_event.VK_4,
"VK_5": keybd_event.VK_5,
"VK_6": keybd_event.VK_6,
"VK_7": keybd_event.VK_7,
"VK_8": keybd_event.VK_8,
"VK_9": keybd_event.VK_9,
"VK_0": keybd_event.VK_0,
"VK_Q": keybd_event.VK_Q,
"VK_W": keybd_event.VK_W,
"VK_E": keybd_event.VK_E,
"VK_R": keybd_event.VK_R,
"VK_T": keybd_event.VK_T,
"VK_Y": keybd_event.VK_Y,
"VK_U": keybd_event.VK_U,
"VK_I": keybd_event.VK_I,
"VK_O": keybd_event.VK_O,
"VK_P": keybd_event.VK_P,
"VK_A": keybd_event.VK_A,
"VK_S": keybd_event.VK_S,
"VK_D": keybd_event.VK_D,
"VK_F": keybd_event.VK_F,
"VK_G": keybd_event.VK_G,
"VK_H": keybd_event.VK_H,
"VK_J": keybd_event.VK_J,
"VK_K": keybd_event.VK_K,
"VK_L": keybd_event.VK_L,
"VK_Z": keybd_event.VK_Z,
"VK_X": keybd_event.VK_X,
"VK_C": keybd_event.VK_C,
"VK_V": keybd_event.VK_V,
"VK_B": keybd_event.VK_B,
"VK_N": keybd_event.VK_N,
"VK_M": keybd_event.VK_M,
"VK_F1": keybd_event.VK_F1,
"VK_F2": keybd_event.VK_F2,
"VK_F3": keybd_event.VK_F3,
"VK_F4": keybd_event.VK_F4,
"VK_F5": keybd_event.VK_F5,
"VK_F6": keybd_event.VK_F6,
"VK_F7": keybd_event.VK_F7,
"VK_F8": keybd_event.VK_F8,
"VK_F9": keybd_event.VK_F9,
"VK_F10": keybd_event.VK_F10,
"VK_F11": keybd_event.VK_F11,
"VK_F12": keybd_event.VK_F12,
"VK_F13": keybd_event.VK_F13,
"VK_F14": keybd_event.VK_F14,
"VK_F15": keybd_event.VK_F15,
"VK_F16": keybd_event.VK_F16,
"VK_F17": keybd_event.VK_F17,
"VK_F18": keybd_event.VK_F18,
"VK_F19": keybd_event.VK_F19,
"VK_F20": keybd_event.VK_F20,
"VK_F21": keybd_event.VK_F21,
"VK_F22": keybd_event.VK_F22,
"VK_F23": keybd_event.VK_F23,
"VK_F24": keybd_event.VK_F24,
"VK_NUMLOCK": keybd_event.VK_NUMLOCK,
"VK_SCROLLLOCK": keybd_event.VK_SCROLLLOCK,
"VK_RESERVED": keybd_event.VK_RESERVED,
"VK_MINUS": keybd_event.VK_MINUS,
"VK_EQUAL": keybd_event.VK_EQUAL,
"VK_BACKSPACE": keybd_event.VK_BACKSPACE,
"VK_TAB": keybd_event.VK_TAB,
"VK_LEFTBRACE": keybd_event.VK_LEFTBRACE,
"VK_RIGHTBRACE": keybd_event.VK_RIGHTBRACE,
"VK_ENTER": keybd_event.VK_ENTER,
"VK_SEMICOLON": keybd_event.VK_SEMICOLON,
"VK_APOSTROPHE": keybd_event.VK_APOSTROPHE,
"VK_GRAVE": keybd_event.VK_GRAVE,
"VK_BACKSLASH": keybd_event.VK_BACKSLASH,
"VK_COMMA": keybd_event.VK_COMMA,
"VK_DOT": keybd_event.VK_DOT,
"VK_SLASH": keybd_event.VK_SLASH,
"VK_KPASTERISK": keybd_event.VK_KPASTERISK,
"VK_SPACE": keybd_event.VK_SPACE,
"VK_CAPSLOCK": keybd_event.VK_CAPSLOCK,
"VK_KP0": keybd_event.VK_KP0,
"VK_KP1": keybd_event.VK_KP1,
"VK_KP2": keybd_event.VK_KP2,
"VK_KP3": keybd_event.VK_KP3,
"VK_KP4": keybd_event.VK_KP4,
"VK_KP5": keybd_event.VK_KP5,
"VK_KP6": keybd_event.VK_KP6,
"VK_KP7": keybd_event.VK_KP7,
"VK_KP8": keybd_event.VK_KP8,
"VK_KP9": keybd_event.VK_KP9,
"VK_KPMINUS": keybd_event.VK_KPMINUS,
"VK_KPPLUS": keybd_event.VK_KPPLUS,
"VK_KPDOT": keybd_event.VK_KPDOT,
"VK_CANCEL": keybd_event.VK_CANCEL,
"VK_BACK": keybd_event.VK_BACK,
"VK_PAUSE": keybd_event.VK_PAUSE,
"VK_HANGUEL": keybd_event.VK_HANGUEL,
"VK_HANJA": keybd_event.VK_HANJA,
"VK_PAGEUP": keybd_event.VK_PAGEUP,
"VK_PAGEDOWN": keybd_event.VK_PAGEDOWN,
"VK_END": keybd_event.VK_END,
"VK_HOME": keybd_event.VK_HOME,
"VK_LEFT": keybd_event.VK_LEFT,
"VK_UP": keybd_event.VK_UP,
"VK_RIGHT": keybd_event.VK_RIGHT,
"VK_DOWN": keybd_event.VK_DOWN,
"VK_PRINT": keybd_event.VK_PRINT,
"VK_INSERT": keybd_event.VK_INSERT,
"VK_DELETE": keybd_event.VK_DELETE,
"VK_HELP": keybd_event.VK_HELP,
}
+1 -32
View File
@@ -3,7 +3,6 @@ package pkg
import (
"errors"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
@@ -12,12 +11,8 @@ import (
"time"
"plg-mudics/shared"
"github.com/micmonay/keybd_event"
)
func GetDeviceIp() (string, error) {
addrs, err := net.InterfaceAddrs()
if err != nil {
@@ -49,29 +44,6 @@ func GetDeviceMac() (string, error) {
return "", fmt.Errorf("no suitable MAC address found")
}
func OpenPresentation(path string) error {
cmd := exec.Command("bash", "-c", "-r", fmt.Sprintf("soffice --show %s -nologo -norestore", path))
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
return errors.New(result.Stderr)
}
return nil
}
func KeyboardInput(key int) error {
kb, err := keybd_event.NewKeyBonding()
if err != nil {
return fmt.Errorf("failed to create key bonding: %w", err)
}
kb.SetKeys(key)
if err := kb.Launching(); err != nil {
return fmt.Errorf("failed to launch key event: %w", err)
}
return nil
}
func TakeScreenshot() (string, error) {
tempFilePath := filepath.Join(os.TempDir(), fmt.Sprintf("screenshot_%d.png", time.Now().Unix()))
@@ -85,12 +57,10 @@ func TakeScreenshot() (string, error) {
if commandOutput.ExitCode == 0 {
return tempFilePath, nil
}
slog.Warn("Screenshot error", "error", commandOutput.Stderr)
}
return "", errors.New("no screenshot utility found or all failed")
}
func GetStoragePath() (string, error) {
var storagePath string
@@ -98,7 +68,7 @@ func GetStoragePath() (string, error) {
if err != nil {
return "", fmt.Errorf("unable to determine user home directory: %w", err)
}
storagePath = filepath.Join(home, ".local", "share", "plg-connect-display")
storagePath = filepath.Join(home, ".local", "share", "plg-mudics", "display")
if err := os.MkdirAll(storagePath, os.ModePerm); err != nil {
return "", fmt.Errorf("failed to create local config directory: %w", err)
}
@@ -136,4 +106,3 @@ func ResolveStorageFilePath(pathParam string) (string, bool, error) {
return fullPath, true, nil
}
+7 -1
View File
@@ -12,10 +12,16 @@ patch {
body:json {
{
"key": "VK_T"
"inputs": [
{
"key": "VK_T",
"action": "press"
}
]
}
}
settings {
encodeUrl: true
timeout: 0
}
+12
View File
@@ -0,0 +1,12 @@
package web
import (
"embed"
"io/fs"
)
//go:embed all:static
var staticDir embed.FS
// BuildDirFS contains the embedded dist directory files (without the "build" prefix)
var StaticDirFS, _ = fs.Sub(staticDir, "static")
+164 -77
View File
@@ -5,37 +5,28 @@ import (
"context"
"errors"
"fmt"
"image/color"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
shared "plg-mudics/shared"
"strings"
"github.com/gabriel-vasile/mimetype"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/micmonay/keybd_event"
"github.com/skip2/go-qrcode"
"plg-mudics/display/pkg"
)
type ErrorResponse struct {
Error string `json:"error"`
}
var version string
var sseConnection chan string
var supportedExtensions = map[string]bool{
".mp4": true,
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".pptx": true,
".odp": true,
}
func StartWebServer(v string, port string) {
version = v
@@ -45,8 +36,17 @@ func StartWebServer(v string, port string) {
e.GET("/", indexRoute)
e.GET("/sse", sseRoute)
e.GET("/splash", func(ctx echo.Context) error {
return ctx.HTML(http.StatusOK, shared.SplashScreenTemplate)
html := shared.SplashScreenTemplate
html = strings.ReplaceAll(html, "%%APP-VERSION%%", version)
return ctx.HTML(http.StatusOK, html)
})
e.GET("/qr", qrRoute)
staticGroup := e.Group("/static")
staticGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Filesystem: http.FS(StaticDirFS),
HTML5: true,
}))
apiGroup := e.Group("/api")
apiGroup.Use(middleware.CORS())
@@ -94,8 +94,9 @@ func sseRoute(ctx echo.Context) error {
if err != nil {
slog.Error("Failed to get device MAC address", "error", err)
}
showQR := !isPortFree(8080)
var status bytes.Buffer
deviceInfoTemplate(ip, mac).Render(context.Background(), &status)
deviceInfoTemplate(ip, mac, showQR).Render(context.Background(), &status)
connectedEvent := Event{
Data: status.Bytes(),
}
@@ -124,14 +125,45 @@ func sseRoute(ctx echo.Context) error {
}
}
func qrRoute(c echo.Context) error {
data := c.QueryParam("data")
if data == "" {
return c.String(http.StatusBadRequest, "missing data")
}
qr, err := qrcode.New(data, qrcode.Medium)
if err != nil {
return c.String(http.StatusInternalServerError, "could not generate qr")
}
qr.DisableBorder = true
qr.ForegroundColor = color.RGBA{R: 0x1c, G: 0x19, B: 0x17, A: 0xff}
qr.BackgroundColor = color.RGBA{R: 0xe7, G: 0xe5, B: 0xe4, A: 0xff}
png, err := qr.PNG(-1)
if err != nil {
return c.String(http.StatusInternalServerError, "could not encode png")
}
return c.Blob(http.StatusOK, "image/png", png)
}
func extractFilePathMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
pathParam := ctx.Param("path")
fullPath, exists, err := pkg.ResolveStorageFilePath(pathParam)
raw := ctx.Param("path")
decoded, err := url.PathUnescape(raw)
if err != nil {
slog.Warn("Failed to validate file path", "path", pathParam, "error", err)
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid file path"})
slog.Warn("Invalid path encoding", "path", raw, "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid file path"})
}
fullPath, exists, err := pkg.ResolveStorageFilePath(decoded)
if err != nil {
slog.Warn("Failed to validate file path", "path", decoded, "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid file path"})
}
ctx.Set("fullPath", fullPath)
ctx.Set("fileExists", exists)
return next(ctx)
@@ -144,14 +176,14 @@ func shellCommandRoute(ctx echo.Context) error {
}
if err := ctx.Bind(&commandInput); err != nil {
slog.Error("Failed to parse shell command", "error", err)
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
}
cmd := exec.Command("bash", "-c", commandInput.Command)
storagePath, err := pkg.GetStoragePath()
if err != nil {
slog.Error("Failed to get storage path", "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to get storage path"})
}
cmd.Dir = storagePath
@@ -166,117 +198,162 @@ func shellCommandRoute(ctx echo.Context) error {
func keyboardInputRoute(ctx echo.Context) error {
var request struct {
Inputs []struct {
Key string `json:"key"`
Action string `json:"action"`
} `json:"inputs"`
}
if err := ctx.Bind(&request); err != nil {
slog.Error("Failed to parse keyboard input", "error", err)
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
}
code, ok := pkg.KeyboardEvents[request.Key]
if !ok {
slog.Error("Unsupported key", "key", request.Key)
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported key: %s", request.Key)})
var inputs []pkg.Input
for _, input := range request.Inputs {
if input.Action != "press" && input.Action != "release" {
slog.Error("Invalid keyboard action", "action", input.Action)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: fmt.Sprintf("Invalid action: %s", input.Action)})
}
err := pkg.KeyboardInput(code)
var action pkg.KeyAction
if input.Action == "press" {
action = pkg.KeyPress
}
if input.Action == "release" {
action = pkg.KeyRelease
}
inputs = append(inputs, pkg.Input{
Key: input.Key,
Action: action,
})
}
err := pkg.KeyboardInput(inputs)
if err != nil {
slog.Error("Failed to send keyboard input", "key", request.Key, "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to send keyboard input"})
slog.Error("Failed to send keyboard input", "inputs", inputs, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to send keyboard input"})
}
slog.Info("Keyboard input sent", "key", request.Key)
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
slog.Info("Keyboard input sent")
return ctx.JSON(http.StatusOK, struct{}{})
}
func uploadFileRoute(ctx echo.Context) error {
var err error
fullPath := ctx.Get("fullPath").(string)
// Ensure parent directories exist
if err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm); err != nil {
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to prepare storage directory"})
slog.Error("Failed to create storage path", "error", err, "path", fullPath)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to prepare storage directory"})
}
if ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusConflict, ErrorResponse{Error: "File already exists"})
return ctx.JSON(http.StatusConflict, shared.ErrorResponse{Description: "File already exists"})
}
ext := strings.ToLower(filepath.Ext(fullPath))
if !supportedExtensions[ext] {
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported file extension: %s", ext)})
}
data, err := io.ReadAll(ctx.Request().Body)
file, err := os.Create(fullPath)
if err != nil {
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to read file body"})
slog.Error("Failed to create file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to create file"})
}
defer func() {
fileCloseErr := file.Close()
if fileCloseErr != nil {
slog.Error("Failed to close file", "file", fullPath, "error", fileCloseErr)
}
if err != nil {
os.Remove(fullPath)
}
}()
_, err = io.Copy(file, ctx.Request().Body)
if err != nil {
slog.Error("Failed to write file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to write file"})
}
if err := os.WriteFile(fullPath, data, os.ModePerm); err != nil {
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to save file"})
err = file.Sync() // ensure data is flushed to disk
if err != nil {
slog.Error("Failed to sync file to disk", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to save file"})
}
slog.Info("File uploaded successfully", "path", fullPath)
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "File uploaded successfully"})
return ctx.JSON(http.StatusOK, struct{}{})
}
func downloadFileRoute(ctx echo.Context) error {
fullPath := ctx.Get("fullPath").(string)
if !ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
}
err := ctx.File(fullPath)
slog.Info("Serving file for download", "path", fullPath)
file, err := os.Open(fullPath)
if err != nil {
slog.Error("Failed to serve file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
slog.Error("Failed to open file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to open file"})
}
defer file.Close()
slog.Info("File downloaded successfully", "path", fullPath)
return nil
return ctx.Stream(http.StatusOK, "application/octet-stream", file)
}
func openFileRoute(ctx echo.Context) error {
var err error
pathParam := ctx.Param("path")
fullPath := ctx.Get("fullPath").(string)
if !ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
}
if sseConnection == nil {
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Cant connect to display browser client"})
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Cant connect to display browser client"})
}
err := resetView()
err = resetView()
if err != nil {
slog.Error("Failed to reset view", "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to reset view"})
}
ext := strings.ToLower(filepath.Ext(fullPath))
switch ext {
case ".mp4":
mType, err := mimetype.DetectFile(fullPath)
if err != nil {
slog.Error("Failed to detect mime type", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to detect file type"})
}
switch mType.String() {
case "video/mp4":
var templateBuffer bytes.Buffer
videoTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String()
case ".jpg", ".jpeg", ".png", ".gif":
case "image/jpeg", "image/png", "image/gif":
var templateBuffer bytes.Buffer
imageTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String()
case ".pptx", ".odp":
err := pkg.OpenPresentation(fullPath)
if err != nil {
slog.Error("Failed to open presentation", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to open presentation"})
}
case "application/pdf", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation":
err = pkg.FileHandler.OpenFile(fullPath)
default:
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Unsupported file type"})
slog.Info("Unsupported file type", "type", mType)
return ctx.JSON(http.StatusUnsupportedMediaType, shared.ErrorResponse{Description: "Unsupported file type: " + mType.String()})
}
if err != nil {
slog.Error("Failed to open file", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to open file"})
}
slog.Info("Successfully run file", "file", pathParam)
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
return ctx.JSON(http.StatusOK, struct{}{})
}
func showHTMLRoute(ctx echo.Context) error {
@@ -285,19 +362,18 @@ func showHTMLRoute(ctx echo.Context) error {
}
if err := ctx.Bind(&request); err != nil {
slog.Error("Failed to parse request", "error", err)
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
}
err := resetView()
if err != nil {
slog.Error("Failed to reset view", "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to reset view"})
}
sseConnection <- request.HTML
slog.Info("HTML content sent to client")
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
return ctx.JSON(http.StatusOK, struct{}{})
}
func pingRoute(ctx echo.Context) error {
@@ -312,13 +388,13 @@ func takeScreenshotRoute(ctx echo.Context) error {
screenshotPath, err := pkg.TakeScreenshot()
if err != nil {
slog.Error("Failed to take screenshot", "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to take screenshot"})
}
err = ctx.File(screenshotPath)
if err != nil {
slog.Error("Failed to serve file", "file", screenshotPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to serve file"})
}
return nil
@@ -328,19 +404,19 @@ func previewRoute(ctx echo.Context) error {
fullPath := ctx.Get("fullPath").(string)
exists := ctx.Get("fileExists").(bool)
if !exists {
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
}
outputFilePath, err := pkg.GenerateFilePreview(fullPath)
if err != nil {
slog.Error("Failed to generate preview", "file", fullPath, "error", err)
if errors.Is(err, pkg.ErrFileTypePreviewNotSupported) {
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "File type not supported for preview"})
return ctx.JSON(http.StatusUnsupportedMediaType, shared.ErrorResponse{Description: "File type not supported for preview"})
}
if errors.Is(err, pkg.ErrFilePreviewToolsMissing) {
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Required tools for file preview are missing"})
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Required tools for file preview are missing"})
}
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to generate preview"})
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to generate preview"})
}
return ctx.File(outputFilePath)
@@ -348,11 +424,22 @@ func previewRoute(ctx echo.Context) error {
// Reset previous file views so they dont collide with the new one
func resetView() error {
err := pkg.KeyboardInput(keybd_event.VK_ESC)
err := pkg.FileHandler.CloseRunningProgram()
if err != nil {
return fmt.Errorf("failed to send ESC key: %w", err)
return fmt.Errorf("failed to close running program: %w", err)
}
sseConnection <- ""
return nil
}
func isPortFree(port int) bool {
addr := fmt.Sprintf("127.0.0.1:%d", port)
l, err := net.Listen("tcp", addr)
if err != nil {
return false
}
_ = l.Close()
return true
}
+64 -22
View File
@@ -6,41 +6,62 @@ templ indexTemplate() {
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>PLG Connect Display</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js" integrity="sha384-Akqfrbj/HpNVo8k11SXBb6TlBWmXXlYQrCSqEWmyKJe+hDm3Z/B2WVG4smwBkRVm" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2" crossorigin="anonymous"></script>
<title>PLG MuDiCS Display</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/htmx-ext-sse.min.js"></script>
<style>
:root {
--background-color: black;
--foreground-color: oklch(92.3% 0.003 48.717);
--font-size: 5rem;
}
body {
display: flex;
justify-content: center; /* centers horizontally */
align-items: center; /* centers vertically */
width: 100vw; /* Viewport width */
height: 100vh; /* Viewport height */
justify-content: center;
/* centers horizontally */
align-items: center;
/* centers vertically */
width: 100vw;
/* Viewport width */
height: 100vh;
/* Viewport height */
margin: 0;
padding: 0;
overflow: hidden;
background-color: hsl(256, 10%, 10%);
color: hsl(256, 90%, 95%);
background-color: var(--background-color);
color: var(--foreground-color);
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
cursor: none;
}
video, img, iframe {
width: 100vw; /* Viewport width */
height: 100vh; /* Viewport height */
video,
img,
iframe {
width: 100vw;
/* Viewport width */
height: 100vh;
/* Viewport height */
object-fit: contain;
}
p {
font-size: 6rem;
line-height: 1;
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
p,
li {
font-size: var(--font-size);
line-height: 1.2;
text-wrap: balance;
max-width: 90vw;
word-break: break-word;
display: block;
margin: 0 0 calc(var(--font-size) * 0.4) 0;
}
ul,
ol {
padding-left: calc(var(--font-size)*1.5);
}
</style>
<script>
document.addEventListener('keydown', function(event) {
document.addEventListener('keydown', function (event) {
if (event.code === 'Space') {
event.preventDefault();
var video = document.querySelector('video');
@@ -72,10 +93,31 @@ templ imageTemplate(path string) {
<img src={ "/api/file/" + path }/>
}
templ deviceInfoTemplate(ip string, mac string) {
<p>
templ deviceInfoTemplate(ip string, mac string, showQR bool) {
<div style="width: 100vw; height: 100vh; display: flex; flex-direction: row; justify-content: space-between;">
<div
style="display: flex; flex-direction: column; gap: 1rem; justify-content: end; padding: 2rem; font-size: 4rem;"
>
{ ip }
<br/>
<span style="text-transform: uppercase;">{ mac }</span>
</p>
</div>
if showQR {
<div style="display: flex; justify-content: end; align-items: end; padding: 2rem;">
<div style="padding: 1rem; background-color: var(--foreground-color); border-radius: 1rem;">
<img
style="height: 30vh; width: auto; image-rendering: pixelated; image-rendering: crisp-edges;"
src={ "/qr?data=http://" + ip + ":8080" }
alt="QR-Code"
/>
</div>
</div>
}
</div>
<style>
:root {
--splash-bg: transparent !important;
--splash-fade-out-state: paused !important;
--background-color: oklch(21.6% 0.006 56.043);
}
</style>
}
+1
View File
@@ -0,0 +1 @@
(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i<r.length;i++){const u=r[i].trim();const c=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(u,c);return}if(!g.triggerEvent(n,"htmx:sseBeforeMessage",e)){return}f(n,e.data);g.triggerEvent(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=c;a.addEventListener(u,c)}}if(g.getAttributeValue(n,"hx-trigger")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var o=g.getTriggerSpecs(n);o.forEach(function(t){if(t.trigger.slice(0,4)!=="sse:"){return}var r=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(t.trigger.slice(4),r)}htmx.trigger(n,t.trigger,e);htmx.trigger(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=r;a.addEventListener(t.trigger.slice(4),r)})}}function i(e,t){if(e==null){return null}if(g.getAttributeValue(e,"sse-connect")){var r=g.getAttributeValue(e,"sse-connect");if(r==null){return}n(e,r,t)}a(e)}function n(r,e,n){var s=htmx.createEventSource(e);s.onerror=function(e){g.triggerErrorEvent(r,"htmx:sseError",{error:e,source:s});if(l(r)){return}if(s.readyState===EventSource.CLOSED){n=n||0;n=Math.max(Math.min(n*2,128),1);var t=n*500;window.setTimeout(function(){i(r,n)},t)}};s.onopen=function(e){g.triggerEvent(r,"htmx:sseOpen",{source:s});if(n&&n>0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e<t.length;e++){a(t[e])}n=0}};g.getInternalData(r).sseEventSource=s;var t=g.getAttributeValue(r,"sse-close");if(t){s.addEventListener(t,function(){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"message"});s.close()})}}function l(e){if(!g.bodyContains(e)){var t=g.getInternalData(e).sseEventSource;if(t!=undefined){g.triggerEvent(e,"htmx:sseClose",{source:t,type:"nodeMissing"});t.close();return true}}return false}function f(t,r){g.withExtensions(t,function(e){r=e.transformResponse(r,null,t)});var e=g.getSwapSpecification(t);var n=g.getTarget(t);g.swap(n,r,e,{contextElement:t})}function v(e){return g.getInternalData(e).sseEventSource!=null}})();
File diff suppressed because one or more lines are too long
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1766997096,
"narHash": "sha256-uOUlyNs3URxA+r8U7jeMe+W2X9kRriP2DqvaeeGzkTQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "088e9a071796304ed9ac3d666164e1ccb9b53f66",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-25.11-small",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+30
View File
@@ -0,0 +1,30 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11-small";
};
outputs = {
self,
nixpkgs,
}: let
pkgs = nixpkgs.legacyPackages."x86_64-linux";
in {
devShells."x86_64-linux".default = pkgs.mkShell {
packages = with pkgs; [
libreoffice
ungoogled-chromium
xreader
imagemagick
ffmpeg
ghostscript
gnome-screenshot
playwright-driver.browsers
];
PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.browsers;
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "true";
PW_DISABLE_TS_ESM = "true";
};
};
}
+10 -2
View File
@@ -1,7 +1,15 @@
github.com/a-h/htmlformat v0.0.0-20250209131833-673be874c677/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4=
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+177
View File
@@ -0,0 +1,177 @@
{
inputs,
config,
pkgs,
...
}: {
imports = [
inputs.home-manager.nixosModules.default
./hardware-configuration.nix
];
## System Config
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
hardware.steam-hardware.enable = true;
networking.networkmanager.enable = true;
nix.settings.experimental-features = ["nix-command" "flakes"];
time.timeZone = "Europe/Berlin";
i18n.defaultLocale = "en_US.UTF-8";
i18n.extraLocaleSettings = {
LC_ADDRESS = "de_DE.UTF-8";
LC_IDENTIFICATION = "de_DE.UTF-8";
LC_MEASUREMENT = "de_DE.UTF-8";
LC_MONETARY = "de_DE.UTF-8";
LC_NAME = "de_DE.UTF-8";
LC_NUMERIC = "de_DE.UTF-8";
LC_PAPER = "de_DE.UTF-8";
LC_TELEPHONE = "de_DE.UTF-8";
LC_TIME = "de_DE.UTF-8";
};
services.xserver.enable = true;
services.xserver.displayManager.lightdm.enable = true;
services.xserver.desktopManager.xfce.enable = true;
services.xserver.xkb = {
layout = "de";
variant = "";
};
console.keyMap = "de";
services.pulseaudio.enable = false;
security.rtkit.enable = true;
services.pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
};
users.users.mudics = {
isNormalUser = true;
description = "mudics";
extraGroups = ["networkmanager" "wheel"];
};
nixpkgs.config.allowUnfree = true;
system.stateVersion = "25.05"; # Don't change
## User Config
programs.nix-ld = {
enable = true;
libraries = with pkgs; [
stdenv.cc.cc
zlib
];
};
services.displayManager.autoLogin = {
enable = true;
user = "mudics";
};
networking.hostName = "mudics";
environment.systemPackages = with pkgs; [
# Programs
libreoffice
ungoogled-chromium
xfce.thunar-archive-plugin
git
nushell
unzip
iputils
xreader
tree
jq
# Libraries
imagemagick
ffmpeg
ghostscript
];
home-manager.users.mudics = {
xfconf.settings = {
xfce4-power-manager."xfce4-power-manager/dpms-enabled" = false;
xfce4-screensaver."saver/enabled" = false;
};
home.stateVersion = "25.05";
};
systemd.services.update-mudics = {
wantedBy = ["multi-user.target"];
after = ["network-online.target"];
wants = ["network-online.target"];
path = config.environment.systemPackages;
script = "nu ${./update.nu}";
serviceConfig = {
WorkingDirectory = "/home/mudics/mudics";
User = "mudics";
Type = "oneshot";
};
};
systemd.services.run-mudics = {
wantedBy = ["default.target"];
after = ["update-mudics.service" "graphical.target"];
wants = ["graphical.target"];
path = config.environment.systemPackages;
script = "./plg-mudics-display";
serviceConfig = {
WorkingDirectory = "/home/mudics/mudics";
User = "mudics";
Type = "simple";
};
environment = {
DISPLAY = ":0";
XDG_RUNTIME_DIR = "/run/user/1000";
XDG_DATA_DIRS = "/run/current-system/sw/share";
};
};
systemd.services.build-mudics-system = {
wantedBy = ["default.target"];
after = ["update-mudics.service"];
path = with pkgs; [nixos-rebuild];
script = "nixos-rebuild boot --flake .#mudics";
serviceConfig = {
WorkingDirectory = "/home/mudics/mudics/nixos";
};
};
systemd.services.enable-wol = {
wantedBy = ["multi-user.target"];
after = ["network.target"];
path = with pkgs; [ethtool nushell];
script = "nu ${./wol.nu}";
serviceConfig = {
Type = "oneshot";
User = "root";
};
};
systemd.tmpfiles.rules = [
"d /home/mudics/mudics 0755 mudics - -"
];
networking.firewall = {
enable = true;
allowedTCPPorts = [
1323 # display
8080 # control
];
};
}
+49
View File
@@ -0,0 +1,49 @@
{
"nodes": {
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1767910483,
"narHash": "sha256-MOU5YdVu4DVwuT5ztXgQpPuRRBjSjUGIdUzOQr9iQOY=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "82fb7dedaad83e5e279127a38ef410bcfac6d77c",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-25.11",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1767799921,
"narHash": "sha256-r4GVX+FToWVE2My8VVZH4V0pTIpnu2ZE8/Z4uxGEMBE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d351d0653aeb7877273920cd3e823994e7579b0b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+27
View File
@@ -0,0 +1,27 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
home-manager = {
url = "github:nix-community/home-manager/release-25.11";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
self,
nixpkgs,
...
} @ inputs: {
nixosConfigurations.mudics = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
inherit inputs;
};
modules = [
./configuration.nix
inputs.home-manager.nixosModules.default
];
};
};
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env nu
print "Checking if we have an internet connection ..."
if (ping google.com -c 5 -W 10 -s 1400 | complete | get exit_code) != 0 {
print "No internet connection. Exiting."
exit 1
}
print "Checking for new version of PLG-MuDiCS ..."
if (new_version_available) {
print "New version available. Trying to update ..."
get_new_display_file
get_new_nixos_config
}
print "Done"
def get_new_nixos_config [] {
let temp_folder_path = (mktemp -d "nixos-temp-XXXXXX")
let temp_file_path = (mktemp "nixos-temp-XXXXXX")
let nixos_config_path = "nixos"
http get https://github.com/PLG-Development/PLG-MuDiCS/releases/latest/download/nixos.zip | save -p -f $temp_file_path
unzip $temp_file_path -d $temp_folder_path
rm -rf $nixos_config_path
mv $temp_folder_path $nixos_config_path
rm $temp_file_path
cp /etc/nixos/hardware-configuration.nix $"($nixos_config_path)/hardware-configuration.nix"
}
def get_new_display_file [] {
let temp_file_path = (mktemp "display-temp-XXXXXX")
http get https://github.com/PLG-Development/PLG-MuDiCS/releases/latest/download/plg-mudics-display | save -p -f $temp_file_path
chmod +x $temp_file_path
mv $temp_file_path plg-mudics-display
}
def new_version_available [] {
let file_path = "version.json"
if not ($file_path | path exists) {
{ "version": ""} | to json | save version.json
}
let current_version = open $file_path | get version
let new_version = http get https://api.github.com/repos/PLG-Development/PLG-MuDiCS/releases/latest --max-time 5sec | get tag_name
# TODO: only write when all operations were successful
{ "version": $new_version} | to json | save version.json -f
if $current_version == $new_version {
false
} else {
true
}
}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env nu
ls /sys/class/net | get name | path basename | where $it != "lo" | each {|iface|
print $"Enabling WoL on ($iface)..."
try {
ethtool -s $iface wol g
print $"Successfully enabled WoL on ($iface)"
} catch {
print $"Failed to enable WoL on ($iface). It might not be supported."
}
}
+5
View File
@@ -2,7 +2,12 @@ package shared
import (
_ "embed"
"strings"
)
//go:embed splash_screen.html
var SplashScreenTemplate string
//go:embed version.txt
var versionNotTrimmed string
var Version = strings.TrimSpace(versionNotTrimmed)
+34
View File
@@ -2,8 +2,10 @@ package shared
import (
"bytes"
"context"
"errors"
"os/exec"
"time"
)
type CommandResponse struct {
@@ -12,7 +14,11 @@ type CommandResponse struct {
ExitCode int `json:"exitCode"`
}
type ErrorResponse struct {
Description string `json:"description"`
}
var BadRequestDescription string = "Request uses invalid JSON syntax or does not follow request schema."
func RunShellCommand(cmd *exec.Cmd) CommandResponse {
var stdout, stderr bytes.Buffer
@@ -20,6 +26,34 @@ func RunShellCommand(cmd *exec.Cmd) CommandResponse {
cmd.Stderr = &stderr
err := cmd.Run()
return parseCmdResult(cmd, stdout, stderr, err)
}
func RunShellCommandNonBlocking(cmd *exec.Cmd) CommandResponse {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Start()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-ctx.Done():
return CommandResponse{}
case err := <-done:
return parseCmdResult(cmd, stdout, stderr, err)
}
}
func parseCmdResult(cmd *exec.Cmd, stdout, stderr bytes.Buffer, err error) CommandResponse {
commandOutput := CommandResponse{
Stdout: stdout.String(),
Stderr: stderr.String(),

Some files were not shown because too many files have changed in this diff Show More