Compare commits

...

19 Commits

Author SHA1 Message Date
2mal3 97274243b8 ci(build): ignore yaml file changes 2025-11-05 22:03:09 +01:00
2mal3 d4f9046f3e ci(release): upload executables 2025-11-05 22:02:35 +01:00
2mal3 78d12cf49f docs(display): update with new tooling 2025-11-05 21:54:22 +01:00
2mal3 bbaf08d6b8 fix: typo in mudics name 2025-11-05 21:50:55 +01:00
2mal3 9d2310dace refactor(display): rename bruno project 2025-11-05 21:49:18 +01:00
2mal3 ceba7b9e56 feat(control): advanced device ping route 2025-11-05 21:48:53 +01:00
2mal3 01e2441943 fix(shared): dont log browser start command 2025-11-05 21:36:08 +01:00
2mal3 3297327321 fix(control): would exit instantly 2025-11-05 21:29:12 +01:00
E44 c042ce5ef6 change display ip to correct ip with port 1323 2025-11-04 21:11:22 +01:00
E44 6c042c8129 change control port to 8080 2025-11-04 21:08:16 +01:00
E44 9f8bf01aba edit and delete display added 2025-11-04 20:24:33 +01:00
E44 d355891517 Fix inconsistent naming and empty displaygroup visual bug 2025-11-04 18:53:29 +01:00
2mal3 80271dc8ae ci: fix 2025-11-04 17:48:37 +01:00
2mal3 edc9d312bc ci: fix control build 2025-11-04 17:43:38 +01:00
2mal3 74470c3bb5 deps(control): update deno.lock 2025-11-04 17:43:20 +01:00
2mal3 012934362a ci: build control block 2025-11-04 17:37:10 +01:00
2mal3 8bbacc6ce5 feat(control): open app in browser on startup 2025-11-04 17:21:49 +01:00
2mal3 4ebac7d7fc refactor: move browser open into shared modules 2025-11-04 17:21:23 +01:00
2mal3 fa3d5198d2 deps(control): update deno lock 2025-11-04 16:51:26 +01:00
26 changed files with 485 additions and 166 deletions
+24 -13
View File
@@ -2,16 +2,23 @@ name: Build
on: on:
push: push:
paths: paths-ignore:
- go.mod - "**/*.md"
- go.sum - "**/*.yml"
- "**/*.go"
- "**/*.tmpl"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
block:
- control
- display
defaults:
run:
working-directory: ${{ matrix.block }}
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -20,21 +27,25 @@ jobs:
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "^1.24" go-version: "^1.24"
cache-dependency-path: display/go.sum cache-dependency-path: go.sum
- name: Setup Deno
uses: denoland/setup-deno@v2
if: matrix.block == 'control'
with:
deno-version: v2.x
- name: Install Dependencies - name: Install Dependencies
working-directory: display
run: go mod download run: go mod download
- name: Generate Templates - name: Generate
working-directory: display run: go generate ./...
run: go tool templ generate
- name: Build - name: Build
working-directory: display run: go build -o plg-mudics-${{ matrix.block }} .
run: go build -o plg-mudics-display .
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
path: display/plg-mudics-display name: plg-mudics-${{ matrix.block }}
path: ${{matrix.block}}/plg-mudics-${{ matrix.block }}
+44
View File
@@ -21,3 +21,47 @@ jobs:
with: with:
name: PLG-MuDiCS ${{ github.ref_name }} name: PLG-MuDiCS ${{ github.ref_name }}
body_path: CHANGELOG.md body_path: CHANGELOG.md
build:
needs: release
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
block:
- control
- display
defaults:
run:
working-directory: ${{ matrix.block }}
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "^1.24"
cache-dependency-path: go.sum
- name: Setup Deno
uses: denoland/setup-deno@v2
if: matrix.block == 'control'
with:
deno-version: v2.x
- name: Install Dependencies
run: go mod download
- name: Generate
run: go generate ./...
- name: Build
run: go build -o plg-mudics-${{ matrix.block }} .
- name: Upload Release Assets
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: plg-mudics-${{ matrix.block }}
tag: ${{ github.ref_name }}
+1 -1
View File
@@ -1 +1 @@
# PLG-MuDICS # PLG-MuDiCS
+1 -1
View File
@@ -1,3 +1,3 @@
# PLG-MuDICS Control # PLG-MuDiCS Control
## Development ## Development
+42
View File
@@ -9,13 +9,16 @@
"npm:@sveltejs/vite-plugin-svelte@6": "6.1.0_svelte@5.37.1__acorn@8.15.0_vite@7.0.6__picomatch@4.0.3", "npm:@sveltejs/vite-plugin-svelte@6": "6.1.0_svelte@5.37.1__acorn@8.15.0_vite@7.0.6__picomatch@4.0.3",
"npm:@tailwindcss/typography@~0.5.15": "0.5.16_tailwindcss@4.1.11", "npm:@tailwindcss/typography@~0.5.15": "0.5.16_tailwindcss@4.1.11",
"npm:@tailwindcss/vite@4": "4.1.11_vite@7.0.6__picomatch@4.0.3", "npm:@tailwindcss/vite@4": "4.1.11_vite@7.0.6__picomatch@4.0.3",
"npm:@thisux/sveltednd@^0.0.20": "0.0.20_svelte@5.37.1__acorn@8.15.0",
"npm:eslint-config-prettier@^10.0.1": "10.1.8_eslint@9.33.0", "npm:eslint-config-prettier@^10.0.1": "10.1.8_eslint@9.33.0",
"npm:eslint-plugin-svelte@3": "3.11.0_eslint@9.33.0_svelte@5.37.1__acorn@8.15.0_postcss@8.5.6", "npm:eslint-plugin-svelte@3": "3.11.0_eslint@9.33.0_svelte@5.37.1__acorn@8.15.0_postcss@8.5.6",
"npm:eslint@^9.18.0": "9.33.0", "npm:eslint@^9.18.0": "9.33.0",
"npm:globals@16": "16.3.0", "npm:globals@16": "16.3.0",
"npm:lucide-svelte@0.545": "0.545.0_svelte@5.37.1__acorn@8.15.0",
"npm:prettier-plugin-svelte@^3.3.3": "3.4.0_prettier@3.6.2_svelte@5.37.1__acorn@8.15.0", "npm:prettier-plugin-svelte@^3.3.3": "3.4.0_prettier@3.6.2_svelte@5.37.1__acorn@8.15.0",
"npm:prettier@^3.4.2": "3.6.2", "npm:prettier@^3.4.2": "3.6.2",
"npm:svelte-check@4": "4.3.0_svelte@5.37.1__acorn@8.15.0_typescript@5.8.3", "npm:svelte-check@4": "4.3.0_svelte@5.37.1__acorn@8.15.0_typescript@5.8.3",
"npm:svelte-dnd-action@~0.9.65": "0.9.65_svelte@5.37.1__acorn@8.15.0",
"npm:svelte@5": "5.37.1_acorn@8.15.0", "npm:svelte@5": "5.37.1_acorn@8.15.0",
"npm:tailwindcss@4": "4.1.11", "npm:tailwindcss@4": "4.1.11",
"npm:typescript-eslint@^8.20.0": "8.40.0_eslint@9.33.0_typescript@5.8.3_@typescript-eslint+parser@8.40.0__eslint@9.33.0__typescript@5.8.3", "npm:typescript-eslint@^8.20.0": "8.40.0_eslint@9.33.0_typescript@5.8.3_@typescript-eslint+parser@8.40.0__eslint@9.33.0__typescript@5.8.3",
@@ -596,6 +599,30 @@
"vite" "vite"
] ]
}, },
"@thisux/sveltednd@0.0.14": {
"integrity": "sha512-Vbq69SU3HUomPg6oCXtb89OG89hka0YIkdaErYibn3waK7tYE66IcQxD/Fzg8YNW3EVsXoA9kc7kW5EUBCSQGg=="
},
"@thisux/sveltednd@0.0.17_svelte@5.37.1__acorn@8.15.0": {
"integrity": "sha512-lRninjw439phhA8xAHqCpMAX0hnwFMdbXW4M0XJgAdnGxeum+QsLiIC4P3HnkNXAygsVKUqxRcbS84CxDZ9hPw==",
"dependencies": [
"@thisux/sveltednd@0.0.14",
"svelte"
]
},
"@thisux/sveltednd@0.0.18_svelte@5.37.1__acorn@8.15.0": {
"integrity": "sha512-MO+iR9ZRHApvtwgWujmqwUbhv9mKs0jLOmeG4tn98GmU1/9wVOi02jrNqeggKg3c8dXz6pMmUHfOfmcq7WwYZg==",
"dependencies": [
"@thisux/sveltednd@0.0.17_svelte@5.37.1__acorn@8.15.0",
"svelte"
]
},
"@thisux/sveltednd@0.0.20_svelte@5.37.1__acorn@8.15.0": {
"integrity": "sha512-VE0HopIlHIvNOfSZ1SsiIzl1AmTU0VSd2Nz2Q2nIdqIUsT/UP4hiLw2ed2jFqTKTbqC+kbCYL9vuBpkYnEw+Kw==",
"dependencies": [
"@thisux/sveltednd@0.0.18_svelte@5.37.1__acorn@8.15.0",
"svelte"
]
},
"@tybys/wasm-util@0.10.0": { "@tybys/wasm-util@0.10.0": {
"integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==",
"dependencies": [ "dependencies": [
@@ -1259,6 +1286,12 @@
"lodash.merge@4.6.2": { "lodash.merge@4.6.2": {
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
}, },
"lucide-svelte@0.545.0_svelte@5.37.1__acorn@8.15.0": {
"integrity": "sha512-lVza6hIAf1abADTU1ThyNp+M3bQZtzTGQ9EQZ2xo3n9ftkd4QcWbDB2ekP2MqRLTJpeLXX5gaFEgFZtbWCnyqg==",
"dependencies": [
"svelte"
]
},
"magic-string@0.30.17": { "magic-string@0.30.17": {
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"dependencies": [ "dependencies": [
@@ -1525,6 +1558,12 @@
], ],
"bin": true "bin": true
}, },
"svelte-dnd-action@0.9.65_svelte@5.37.1__acorn@8.15.0": {
"integrity": "sha512-GKFtrAtYAjcm27aMELoXOhkLtKA1AEoj2njjCReCer6jh1hnRtTHdEO4Kjfpayz+ZAvE0MMwIvLISW3tsiO9Qg==",
"dependencies": [
"svelte"
]
},
"svelte-eslint-parser@1.3.1_svelte@5.37.1__acorn@8.15.0_postcss@8.5.6": { "svelte-eslint-parser@1.3.1_svelte@5.37.1__acorn@8.15.0_postcss@8.5.6": {
"integrity": "sha512-0Iztj5vcOVOVkhy1pbo5uA9r+d3yaVoE5XPc9eABIWDOSJZ2mOsZ4D+t45rphWCOr0uMw3jtSG2fh2e7GvKnPg==", "integrity": "sha512-0Iztj5vcOVOVkhy1pbo5uA9r+d3yaVoE5XPc9eABIWDOSJZ2mOsZ4D+t45rphWCOr0uMw3jtSG2fh2e7GvKnPg==",
"dependencies": [ "dependencies": [
@@ -1689,13 +1728,16 @@
"npm:@sveltejs/vite-plugin-svelte@6", "npm:@sveltejs/vite-plugin-svelte@6",
"npm:@tailwindcss/typography@~0.5.15", "npm:@tailwindcss/typography@~0.5.15",
"npm:@tailwindcss/vite@4", "npm:@tailwindcss/vite@4",
"npm:@thisux/sveltednd@^0.0.20",
"npm:eslint-config-prettier@^10.0.1", "npm:eslint-config-prettier@^10.0.1",
"npm:eslint-plugin-svelte@3", "npm:eslint-plugin-svelte@3",
"npm:eslint@^9.18.0", "npm:eslint@^9.18.0",
"npm:globals@16", "npm:globals@16",
"npm:lucide-svelte@0.545",
"npm:prettier-plugin-svelte@^3.3.3", "npm:prettier-plugin-svelte@^3.3.3",
"npm:prettier@^3.4.2", "npm:prettier@^3.4.2",
"npm:svelte-check@4", "npm:svelte-check@4",
"npm:svelte-dnd-action@~0.9.65",
"npm:svelte@5", "npm:svelte@5",
"npm:tailwindcss@4", "npm:tailwindcss@4",
"npm:typescript-eslint@^8.20.0", "npm:typescript-eslint@^8.20.0",
+2
View File
@@ -5,7 +5,9 @@ import (
"io/fs" "io/fs"
) )
//go:generate deno install
//go:generate deno task build //go:generate deno task build
//go:embed all:build //go:embed all:build
var buildDir embed.FS var buildDir embed.FS
@@ -1,14 +1,22 @@
<script lang="ts"> <script lang="ts">
import { ArrowBigLeft, ArrowBigRight, ChevronDown, Keyboard, Power, PowerOff, Presentation, SquareTerminal, TextAlignStart, TrafficCone } from "lucide-svelte"; import {
import { selected_display_ids } from "../ts/stores/select"; ArrowBigLeft,
import Button from "./Button.svelte"; ArrowBigRight,
ChevronDown,
Keyboard,
Power,
PowerOff,
Presentation,
SquareTerminal,
TextAlignStart,
TrafficCone
} from 'lucide-svelte';
import Button from './Button.svelte';
</script> </script>
<div class="grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl min-w-0"> <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"> <div class="text-xl font-bold pl-3 content-center bg-stone-700 rounded-t-2xl truncate min-w-0">
{'Bildschirme steuern (' + $selected_display_ids.length + ' ausgewählt)'} Bildschirme steuern
</div> </div>
<div class="flex flex-col gap-2 p-2 overflow-auto"> <div class="flex flex-col gap-2 p-2 overflow-auto">
<div class="flex flex-row justify-between gap-2"> <div class="flex flex-row justify-between gap-2">
@@ -13,11 +13,12 @@
} from '../ts/stores/displays'; } from '../ts/stores/displays';
import DNDGrip from './DNDGrip.svelte'; import DNDGrip from './DNDGrip.svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { DisplayGroup } from '../ts/types'; import type { DisplayGroup, MenuOption } from '../ts/types';
import { selected_display_ids } from '../ts/stores/select'; import { selected_display_ids } from '../ts/stores/select';
let { display_group } = $props<{ let { display_group, get_display_menu_options } = $props<{
display_group: DisplayGroup; display_group: DisplayGroup;
get_display_menu_options: (display_id: string) => MenuOption[]
}>(); }>();
let hovering_selectable = $state(false); let hovering_selectable = $state(false);
@@ -76,7 +77,7 @@
class="outline-none" class="outline-none"
role="figure" role="figure"
> >
<DisplayObject {display} /> <DisplayObject {display} {get_display_menu_options} />
</section> </section>
{/each} {/each}
@@ -8,11 +8,12 @@
import DNDGrip from './DNDGrip.svelte'; import DNDGrip from './DNDGrip.svelte';
import { Menu, Pencil, Pin, PinOff, Trash2, VideoOff, X } from 'lucide-svelte'; import { Menu, Pencil, Pin, PinOff, Trash2, VideoOff, X } from 'lucide-svelte';
import OnlineState from './OnlineState.svelte'; import OnlineState from './OnlineState.svelte';
import type { Display } from '../ts/types'; import type { Display, MenuOption } from '../ts/types';
import { is_selected, select, selected_display_ids } from '../ts/stores/select'; import { is_selected, select, selected_display_ids } from '../ts/stores/select';
let { display } = $props<{ let { display, get_display_menu_options } = $props<{
display: Display; display: Display;
get_display_menu_options: (display_id: string) => MenuOption[];
}>(); }>();
let hovering_unselectable = $state(false); let hovering_unselectable = $state(false);
@@ -121,17 +122,7 @@
click_function={(e) => { click_function={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
menu_options={[ menu_options={get_display_menu_options(display.id)}
{
icon: Pencil,
name: 'Bildschirm bearbeiten'
},
{
icon: Trash2,
name: 'Bildschirm löschen',
class: 'text-red-400 hover:text-stone-200 hover:!bg-red-400 active:!bg-red-500'
}
]}
> >
<Menu /> <Menu />
</Button> </Button>
@@ -16,18 +16,42 @@
next_height_step_size, next_height_step_size,
pinned_display_id pinned_display_id
} from '../ts/stores/ui_behavior'; } from '../ts/stores/ui_behavior';
import { type Display, type DisplayGroup } from '../ts/types'; import { type Display, type DisplayGroup, type MenuOption } from '../ts/types';
import Button from './Button.svelte'; import Button from './Button.svelte';
import OnlineState from './OnlineState.svelte'; import OnlineState from './OnlineState.svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { Menu, Minus, Pencil, PinOff, Plus, Trash2, VideoOff } from 'lucide-svelte'; import { Menu, Minus, Pencil, PinOff, Plus, Settings, Trash2, VideoOff } from 'lucide-svelte';
import { selected_display_ids } from '../ts/stores/select'; import { selected_display_ids } from '../ts/stores/select';
import { dragHandleZone } from 'svelte-dnd-action'; import { dragHandleZone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import DisplayGroupObject from './DisplayGroupObject.svelte'; 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 displays_scroll_box: HTMLElement;
let pinned_display: Display | null = $derived(get_display_by_id($pinned_display_id || '', $displays)); 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[]) { function select_all(current_displays: DisplayGroup[], current_selected_display_ids: string[]) {
const new_value = !all_selected(current_displays, current_selected_display_ids); const new_value = !all_selected(current_displays, current_selected_display_ids);
@@ -87,17 +111,7 @@
click_function={(e) => { click_function={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
menu_options={[ menu_options={get_display_menu_options($pinned_display_id)}
{
icon: Pencil,
name: 'Bildschirm bearbeiten'
},
{
icon: Trash2,
name: 'Bildschirm löschen',
class: 'text-red-400 hover:text-stone-200 hover:!bg-red-400 active:!bg-red-500'
}
]}
> >
<Menu /> <Menu />
</Button> </Button>
@@ -136,7 +150,7 @@
<!-- Normal Heading Left --> <!-- Normal Heading Left -->
<div class="bg-stone-700 flex justify-between w-full p-1 gap-2 min-w-0"> <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"> <span class="text-xl font-bold pl-2 content-center truncate min-w-0">
Bereits verbundene Displays Verbundene Bildschirme
</span> </span>
<div class="flex flex-row gap-1"> <div class="flex flex-row gap-1">
<button <button
@@ -157,7 +171,7 @@
: 'Alle auswählen'}</span : 'Alle auswählen'}</span
> >
</button> </button>
<div class="flex flex-ro"> <div class="flex flex-row">
<Button <Button
title="Bildschirme größer darstellen" title="Bildschirme größer darstellen"
className="aspect-square !p-1 rounded-r-none" className="aspect-square !p-1 rounded-r-none"
@@ -202,6 +216,13 @@
$is_group_drag = false; $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 $displays as display_group (display_group.id)}
<!-- Each Group --> <!-- Each Group -->
<section <section
@@ -209,9 +230,10 @@
animate:flip={{ duration: dnd_flip_duration_ms, easing: cubicOut }} animate:flip={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
class="outline-none" class="outline-none"
> >
<DisplayGroupObject {display_group} /> <DisplayGroupObject {display_group} {get_display_menu_options} />
</section> </section>
{/each} {/each}
{/if}
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -38,7 +38,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="bg-stone-800 rounded-2xl min-w-[30%] min-h-[30%] max-w-[80%] max-h-[80%] flex flex-col shadow-2xl/60 overflow-hidden" 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()} onclick={(e) => e.stopPropagation()}
> >
<div <div
@@ -62,7 +62,7 @@
</div> </div>
</div> </div>
<div class="p-2 min-h-0 overflow-auto flex flex-col gap-2"> <div class="p-2 min-h-0 overflow-auto flex flex-col gap-2">
{@render content.snippet()} {@render content.snippet(content.snippet_arg)}
</div> </div>
</div> </div>
</div> </div>
+96 -22
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Monitor, Plus, Radio, Settings, X } from 'lucide-svelte'; import { Monitor, Plus, Radio, Settings, Trash2, X } from 'lucide-svelte';
import Button from '../components/Button.svelte'; import Button from '../components/Button.svelte';
import FileView from '../components/FileView.svelte'; import FileView from '../components/FileView.svelte';
import ControlView from '../components/ControlView.svelte'; import ControlView from '../components/ControlView.svelte';
@@ -8,7 +8,14 @@
import PopUp from '../components/PopUp.svelte'; import PopUp from '../components/PopUp.svelte';
import type { PopupContent } from '../ts/types'; import type { PopupContent } from '../ts/types';
import TextInput from '../components/TextInput.svelte'; import TextInput from '../components/TextInput.svelte';
import { add_display, is_display_name_taken } from '../ts/stores/displays'; 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'; import { text } from '@sveltejs/kit';
const ip_regex = const ip_regex =
@@ -22,11 +29,12 @@
closable: true closable: true
}); });
let text_inputs_valid = $state({ const text_inputs_valid_null_values = {
name: { valid: false, value: '' }, name: { valid: false, value: '' },
ip: { valid: false, value: '' }, ip: { valid: false, value: '' },
mac: { valid: false, value: '' } mac: { valid: false, value: '' }
}); };
let text_inputs_valid = $state(text_inputs_valid_null_values);
function all_text_inputs_valid(): boolean { function all_text_inputs_valid(): boolean {
for (const entry of Object.values(text_inputs_valid)) { for (const entry of Object.values(text_inputs_valid)) {
@@ -37,17 +45,16 @@
return true; return true;
} }
function finalize_add_new_display() { function finalize_add_edit_display(existing_display_id: string|null) {
const ip = text_inputs_valid.ip.value; const ip = text_inputs_valid.ip.value;
const mac = text_inputs_valid.mac.value === '' ? null : text_inputs_valid.mac.value; const mac = text_inputs_valid.mac.value === '' ? null : text_inputs_valid.mac.value;
const name = text_inputs_valid.name.value; const name = text_inputs_valid.name.value;
if (!!existing_display_id) {
edit_display_data(existing_display_id, ip, mac, name);
} else {
add_display(ip, mac, name, 'Online'); add_display(ip, mac, name, 'Online');
}
popup_close_function(); popup_close_function();
text_inputs_valid = {
name: { valid: false, value: '' },
ip: { valid: false, value: '' },
mac: { valid: false, value: '' }
};
} }
function popup_close_function() { function popup_close_function() {
@@ -55,28 +62,81 @@
} }
const show_new_display_popup = () => { const show_new_display_popup = () => {
text_inputs_valid = text_inputs_valid_null_values;
popup_content = { popup_content = {
open: true, open: true,
snippet: add_new_display, snippet: add_new_display_popup,
title: 'Neuen Bildschirm hinzufügen', title: 'Neuen Bildschirm hinzufügen',
title_icon: Monitor, title_icon: Monitor,
closable: true closable: true
}; };
}; };
const show_remove_display_popup = (display_id: string) => {
popup_content = {
open: true,
snippet: remove_display_popup,
snippet_arg: display_id,
title: 'Bildschirm wirklich löschen?',
title_class: 'text-red-400',
title_icon: Trash2,
closable: true
};
};
const show_edit_display_popup = (display_id: string) => {
const display = get_display_by_id(display_id, $displays);
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)[]) {
text_inputs_valid[key].valid = true;
text_inputs_valid[key].value = display[key] || '';
}
popup_content = {
open: true,
snippet: add_new_display_popup,
snippet_arg: display_id,
title: 'Bildschirm bearbeiten',
title_icon: Monitor,
closable: true
};
};
</script> </script>
{#snippet add_new_display()} {#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.
</div>
<div class="flex flex-row justify-end gap-2">
<Button className="px-4 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);
popup_close_function();
}}>Löschen</Button
>
</div>
{/snippet}
{#snippet add_new_display_popup(existing_display_id: string | null = null)}
<TextInput <TextInput
bind:current_value={text_inputs_valid.name.value} bind:current_value={text_inputs_valid.name.value}
bind:current_valid={text_inputs_valid.name.valid} bind:current_valid={text_inputs_valid.name.valid}
title="Anzeigename" title="Anzeigename"
placeholder="z.B. Beamer vorne links" placeholder="z.B. Beamer vorne links"
is_valid_function={(input: string) => { is_valid_function={(input: string) => {
return input.length === 0 || input.length > 50 if (!!existing_display_id) {
? [false, 'Ungültige Länge'] if (input === get_display_by_id(existing_display_id, $displays)?.name)
: is_display_name_taken(input) return [true, 'Gültiger Name'];
? [false, 'Name bereits verwendet'] }
: [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'];
return [true, 'Gültiger Name'];
}} }}
/> />
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
@@ -111,13 +171,24 @@
: [false, 'Ungültige MAC-Adresse']; : [false, 'Ungültige MAC-Adresse'];
}} }}
/> />
<div class="flex justify-end pt-2"> <div class="flex flex-row gap-2 justify-end pt-2">
{#if !!existing_display_id}
<!-- TODO: Ping mit existing_display_id -->
<Button className="px-4" click_function={popup_close_function}>Abbrechen</Button>
{/if}
<Button <Button
disabled={!all_text_inputs_valid()} disabled={!all_text_inputs_valid()}
className="pl-3 pr-4 gap-2 font-bold" className="{!!existing_display_id ? 'px-4' : 'pl-3 pr-4 gap-2'} font-bold"
bg="bg-stone-650" bg="bg-stone-650"
click_function={finalize_add_new_display}><Plus /> Bildschirm hinzufügen</Button click_function={() => {
> finalize_add_edit_display(existing_display_id);
}}
>{#if !!existing_display_id}
Speichern
{:else}
<Plus /> Bildschirm hinzufügen
{/if}
</Button>
</div> </div>
{/snippet} {/snippet}
@@ -146,7 +217,10 @@
</Button> </Button>
</div> </div>
<div class="w-[calc(100dvw-(8*var(--spacing)))] grid grid-cols-2 gap-2"> <div class="w-[calc(100dvw-(8*var(--spacing)))] grid grid-cols-2 gap-2">
<DisplayView /> <DisplayView
handle_display_deletion={show_remove_display_popup}
handle_display_editing={show_edit_display_popup}
/>
<div <div
class="col-start-2 h-[calc(100dvh-3rem-(6*var(--spacing)))] rounded-2xl flex flex-col gap-2" class="col-start-2 h-[calc(100dvh-3rem-(6*var(--spacing)))] rounded-2xl flex flex-col gap-2"
> >
+26 -1
View File
@@ -24,6 +24,31 @@ export function add_display(ip: string, mac: string|null, name: string, status:
}); });
} }
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[]) { export function all_displays_of_group_selected(display_group: DisplayGroup, current_selected_displays: string[]) {
if (display_group.data.length === 0) return false; if (display_group.data.length === 0) return false;
for (const display of display_group.data) { for (const display of display_group.data) {
@@ -139,6 +164,6 @@ function add_testing_displays() {
// 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", name, "Offline");
// } // }
// add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", "PC", "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"); // add_display("192.168.178.111", "D4:81:D7:C0:DF:3C", "Laptop", "Online");
} }
+3 -1
View File
@@ -1,4 +1,5 @@
import { FileBox, FileImage, FileVideoCamera, ImagePlay, type X } from "lucide-svelte"; import { FileBox, FileImage, FileVideoCamera, ImagePlay, type X } from "lucide-svelte";
import type { Snippet } from "svelte";
export type SupportedFileType = { export type SupportedFileType = {
display_name: display_name:
@@ -81,7 +82,8 @@ export type MenuOption = {
export type PopupContent = { export type PopupContent = {
open: boolean; open: boolean;
snippet: any; snippet: Snippet<[string]> | null;
snippet_arg?: string;
title: string; title: string;
title_class?: string; title_class?: string;
title_icon?: typeof X | null; title_icon?: typeof X | null;
+36 -4
View File
@@ -2,8 +2,12 @@ package main
import ( import (
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os/exec"
"plg-mudics/control/frontend" "plg-mudics/control/frontend"
"plg-mudics/shared"
"time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
@@ -20,12 +24,40 @@ func main() {
// Servers all API endpoints, e.g. our custom logic // Servers all API endpoints, e.g. our custom logic
apiGroup := e.Group("/api") apiGroup := e.Group("/api")
apiGroup.GET("/ping", func(ctx echo.Context) error { apiGroup.GET("/ping", pingRoute)
return ctx.String(http.StatusOK, "pong")
})
err := e.Start(":1323") port := "8080"
go shared.OpenBrowserWindow("http://localhost:"+port, false, false)
err := e.Start(":" + port)
if err != nil { if err != nil {
slog.Error("Failed to start Echo Webserver", "error", err) slog.Error("Failed to start Echo Webserver", "error", err)
} }
} }
func pingRoute(ctx echo.Context) error {
ip := ctx.QueryParam("ip")
if ip == "" {
return ctx.JSON(http.StatusBadRequest, PingResponse{Error: "missing 'ip' query parameter"})
}
cmd := exec.Command("ping", "-c", "1", "-w", "1", 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)
if err != nil {
return ctx.JSON(http.StatusOK, PingResponse{Status: "app_offline"})
}
conn.Close()
return ctx.JSON(http.StatusOK, PingResponse{Status: "app_online"})
}
type PingResponse struct {
Status string `json:"status"`
Error string `json:"error"`
}
+9
View File
@@ -0,0 +1,9 @@
{
"version": "1",
"name": "PLG-MuDiCS Control",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}
+20
View File
@@ -0,0 +1,20 @@
meta {
name: ping
type: http
seq: 1
}
get {
url: http://127.0.0.1:8080/api/ping?ip=127.0.0.1
body: none
auth: inherit
}
params:query {
ip: 127.0.0.1
}
settings {
encodeUrl: true
timeout: 0
}
+3 -3
View File
@@ -1,4 +1,4 @@
# PLG-MuDICS Display # PLG-MuDiCS Display
## Runtime Requirements ## Runtime Requirements
@@ -11,6 +11,6 @@
## Development Setup ## Development Setup
- install Go (at least version v1.24) - install Go (at least version v1.25)
- build template: `go tool templ generate` - build template: `go generate ./..`
- run code: `go run .` - run code: `go run .`
+4 -3
View File
@@ -6,6 +6,7 @@ import (
"plg-mudics/display/pkg" "plg-mudics/display/pkg"
"plg-mudics/display/web" "plg-mudics/display/web"
"plg-mudics/shared"
) )
const VERSION = "0.1.0" const VERSION = "0.1.0"
@@ -22,10 +23,10 @@ func main() {
os.Exit(1) os.Exit(1)
return return
} }
port := "1323"
go web.StartWebServer(VERSION, port)
go web.StartWebServer(VERSION) err = shared.OpenBrowserWindow("http://localhost:"+port, true, true)
err = pkg.OpenBrowserWindow("http://127.0.0.1:1323")
if err != nil { if err != nil {
slog.Error("Failed to open browser window", "error", err) slog.Error("Failed to open browser window", "error", err)
os.Exit(1) os.Exit(1)
+5 -4
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"plg-mudics/shared"
"strings" "strings"
"time" "time"
) )
@@ -36,7 +37,7 @@ func GenerateFilePreview(inputPath string) (string, error) {
func generateImagePreview(inputPath string, outputPath string) error { func generateImagePreview(inputPath string, outputPath string) error {
cmd := exec.Command("magick", inputPath, "-thumbnail", "100x100", "-quality", "50", outputPath) cmd := exec.Command("magick", inputPath, "-thumbnail", "100x100", "-quality", "50", outputPath)
result := RunShellCommand(cmd) result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 { if result.ExitCode != 0 {
if result.ExitCode == 127 { if result.ExitCode == 127 {
return ErrFilePreviewToolsMissing return ErrFilePreviewToolsMissing
@@ -48,12 +49,12 @@ func generateImagePreview(inputPath string, outputPath string) error {
func generatePDFPreview(inputPath string, outputPath string) error { func generatePDFPreview(inputPath string, outputPath string) error {
testCmd := exec.Command("which", "gs") testCmd := exec.Command("which", "gs")
if result := RunShellCommand(testCmd); result.ExitCode != 0 { if result := shared.RunShellCommand(testCmd); result.ExitCode != 0 {
return ErrFilePreviewToolsMissing 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), "-thumbnail", "100x100", "-quality", "50", outputPath)
result := RunShellCommand(cmd) result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 { if result.ExitCode != 0 {
if result.ExitCode == 127 { if result.ExitCode == 127 {
return ErrFilePreviewToolsMissing return ErrFilePreviewToolsMissing
@@ -67,7 +68,7 @@ func generateVideoPreview(inputPath string, outputPath string) error {
tempFilePath := filepath.Join(os.TempDir(), fmt.Sprintf("preview_temp_%d.webp", time.Now().Unix())) tempFilePath := filepath.Join(os.TempDir(), fmt.Sprintf("preview_temp_%d.webp", time.Now().Unix()))
ffmpegCmd := exec.Command("ffmpeg", "-i", inputPath, "-ss", "00:00:01.000", "-vframes", "1", tempFilePath) ffmpegCmd := exec.Command("ffmpeg", "-i", inputPath, "-ss", "00:00:01.000", "-vframes", "1", tempFilePath)
result := RunShellCommand(ffmpegCmd) result := shared.RunShellCommand(ffmpegCmd)
if result.ExitCode != 0 { if result.ExitCode != 0 {
if result.ExitCode == 127 { if result.ExitCode == 127 {
return ErrFilePreviewToolsMissing return ErrFilePreviewToolsMissing
+5 -47
View File
@@ -1,7 +1,6 @@
package pkg package pkg
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -12,14 +11,12 @@ import (
"strings" "strings"
"time" "time"
"plg-mudics/shared"
"github.com/micmonay/keybd_event" "github.com/micmonay/keybd_event"
) )
type CommandResponse struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exitCode"`
}
func GetDeviceIp() (string, error) { func GetDeviceIp() (string, error) {
addrs, err := net.InterfaceAddrs() addrs, err := net.InterfaceAddrs()
@@ -54,7 +51,7 @@ func GetDeviceMac() (string, error) {
func OpenPresentation(path string) error { func OpenPresentation(path string) error {
cmd := exec.Command("bash", "-c", "-r", fmt.Sprintf("soffice --show %s -nologo -norestore", path)) cmd := exec.Command("bash", "-c", "-r", fmt.Sprintf("soffice --show %s -nologo -norestore", path))
result := RunShellCommand(cmd) result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 { if result.ExitCode != 0 {
return errors.New(result.Stderr) return errors.New(result.Stderr)
} }
@@ -84,7 +81,7 @@ func TakeScreenshot() (string, error) {
exec.Command("spectacle", "--fullscreen", "--nonotify", "--background", "--output", tempFilePath), exec.Command("spectacle", "--fullscreen", "--nonotify", "--background", "--output", tempFilePath),
} }
for _, cmd := range cmds { for _, cmd := range cmds {
commandOutput := RunShellCommand(cmd) commandOutput := shared.RunShellCommand(cmd)
if commandOutput.ExitCode == 0 { if commandOutput.ExitCode == 0 {
return tempFilePath, nil return tempFilePath, nil
} }
@@ -93,45 +90,6 @@ func TakeScreenshot() (string, error) {
return "", errors.New("no screenshot utility found or all failed") return "", errors.New("no screenshot utility found or all failed")
} }
func RunShellCommand(cmd *exec.Cmd) CommandResponse {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
commandOutput := CommandResponse{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExitCode: cmd.ProcessState.ExitCode(),
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
commandOutput.ExitCode = exitErr.ExitCode()
} else {
commandOutput.Stderr = err.Error()
}
}
return commandOutput
}
func OpenBrowserWindow(url string) error {
template := "%s --app='%s' --start-fullscreen --user-data-dir=$(mktemp -d) --autoplay-policy=no-user-gesture-required"
cmds := []*exec.Cmd{
exec.Command("bash", "-c", fmt.Sprintf(template, "chromium", url)),
exec.Command("bash", "-c", fmt.Sprintf(template, "chromium-browser", url)),
}
for _, cmd := range cmds {
commandOutput := RunShellCommand(cmd)
if commandOutput.ExitCode == 0 {
return nil
}
}
return errors.New("chromium not found in PATH")
}
func GetStoragePath() (string, error) { func GetStoragePath() (string, error) {
var storagePath string var storagePath string
+5 -2
View File
@@ -1,6 +1,9 @@
{ {
"version": "1", "version": "1",
"name": "PLG-MuDICS", "name": "PLG-MuDiCS Display",
"type": "collection", "type": "collection",
"ignore": ["node_modules", ".git"] "ignore": [
"node_modules",
".git"
]
} }
+3 -3
View File
@@ -37,7 +37,7 @@ var supportedExtensions = map[string]bool{
".odp": true, ".odp": true,
} }
func StartWebServer(v string) { func StartWebServer(v string, port string) {
version = v version = v
e := echo.New() e := echo.New()
@@ -63,7 +63,7 @@ func StartWebServer(v string) {
fileGroup.PATCH("/:path", openFileRoute) fileGroup.PATCH("/:path", openFileRoute)
fileGroup.GET("/preview/:path", previewRoute) fileGroup.GET("/preview/:path", previewRoute)
err := e.Start(":1323") err := e.Start(":" + port)
if err != nil { if err != nil {
slog.Error("Failed to start server", "error", err) slog.Error("Failed to start server", "error", err)
} }
@@ -155,7 +155,7 @@ func shellCommandRoute(ctx echo.Context) error {
} }
cmd.Dir = storagePath cmd.Dir = storagePath
commandOutput := pkg.RunShellCommand(cmd) commandOutput := shared.RunShellCommand(cmd)
if commandOutput.ExitCode != 0 { if commandOutput.ExitCode != 0 {
slog.Error("Shell command execution error", "error", commandOutput.Stderr) slog.Error("Shell command execution error", "error", commandOutput.Stderr)
} }
+8
View File
@@ -0,0 +1,8 @@
package shared
import (
_ "embed"
)
//go:embed splash_screen.html
var SplashScreenTemplate string
+33 -3
View File
@@ -1,8 +1,38 @@
package shared package shared
import ( import (
_ "embed" "bytes"
"errors"
"os/exec"
) )
//go:embed splash_screen.html type CommandResponse struct {
var SplashScreenTemplate string Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exitCode"`
}
func RunShellCommand(cmd *exec.Cmd) CommandResponse {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
commandOutput := CommandResponse{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExitCode: cmd.ProcessState.ExitCode(),
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
commandOutput.ExitCode = exitErr.ExitCode()
} else {
commandOutput.Stderr = err.Error()
}
}
return commandOutput
}
+35
View File
@@ -0,0 +1,35 @@
package shared
import (
"errors"
"fmt"
"os"
"os/exec"
)
func OpenBrowserWindow(url string, fullscreen bool, temp bool) error {
bins := []string{"chromium", "chromium-browser"}
args := []string{fmt.Sprintf("--app=%s", url), "--autoplay-policy=no-user-gesture-required"}
if fullscreen {
args = append(args, "--start-fullscreen")
}
if temp {
tempDirPath, err := os.MkdirTemp("", "plg-mudics-browser-")
if err != nil {
return err
}
arg := fmt.Sprintf("--user-data-dir=%s", tempDirPath)
args = append(args, arg)
}
for _, bin := range bins {
cmd := exec.Command(bin, args...)
commandOutput := RunShellCommand(cmd)
if commandOutput.ExitCode == 0 {
return nil
}
}
return errors.New("chromium not found in PATH")
}