Compare commits

..

1 Commits

Author SHA1 Message Date
2mal3 cd6ec88d23 chore(openBrowser): more detailed error logs 2025-11-07 12:42:19 +01:00
60 changed files with 821 additions and 5292 deletions
-1
View File
@@ -5,7 +5,6 @@ on:
paths-ignore:
- "**/*.md"
- "**/*.yml"
- "**/*.sh"
workflow_dispatch:
jobs:
-4
View File
@@ -17,15 +17,11 @@ jobs:
- 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
-10
View File
@@ -1,10 +0,0 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"golang.go",
"esbenp.prettier-vscode",
"svelte.svelte-vscode",
"a-h.templ",
"bradlc.vscode-tailwindcss"
]
}
-34
View File
@@ -1,34 +0,0 @@
# 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
-17
View File
@@ -1,20 +1,3 @@
# 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
- Linux
- Bash
- Chromium
- LibreOffice (optional, presentation view)
- Xreader (optional, pdf view)
- ImageMagick (optional, image preview)
- ffmpeg (optional, video preview)
- Ghostscript (optional, pdf preview)
## Contributing
See [Contributing.md](/CONTRIBUTING.md)
+3
View File
@@ -0,0 +1,3 @@
# PLG-MuDiCS Control
## Development
+43 -745
View File
File diff suppressed because it is too large Load Diff
+35 -1559
View File
File diff suppressed because it is too large Load Diff
+2 -11
View File
@@ -20,15 +20,14 @@
"@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",
"@tiptap/extension-placeholder": "^3.11.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",
"sass-embedded": "^1.93.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-dnd-action": "^0.9.65",
@@ -39,14 +38,6 @@
},
"dependencies": {
"@thisux/sveltednd": "^0.0.20",
"@tiptap/core": "^3.11.0",
"@tiptap/extension-highlight": "^3.11.0",
"@tiptap/extension-text-style": "^3.11.0",
"@tiptap/extensions": "^3.11.0",
"@tiptap/pm": "^3.11.0",
"@tiptap/starter-kit": "^3.11.0",
"dexie": "^4.2.1",
"lucide-svelte": "^0.545.0",
"svelte-splitpanes": "^8.0.9"
"lucide-svelte": "^0.545.0"
}
}
+11 -66
View File
@@ -1,70 +1,15 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
/* Adding more color steps */
@theme {
--color-stone-150: oklch(0.9465 0.002 77.571);
--color-stone-250: oklch(0.896 0.004 52.542);
--color-stone-350: oklch(0.789 0.0075 56.313);
--color-stone-450: oklch(0.631 0.0115 57.165);
--color-stone-550: oklch(0.4985 0.012 65.855);
--color-stone-650: oklch(0.409 0.0105 70.599);
--color-stone-750: oklch(0.321 0.0085 50.928);
--color-stone-850: oklch(0.242 0.0065 45.171);
}
/* 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))];
}
}
}
--color-stone-150: oklch(0.9465 0.002 77.571);
--color-stone-250: oklch(0.896 0.004 52.542);
--color-stone-350: oklch(0.789 0.0075 56.313);
--color-stone-450: oklch(0.631 0.0115 57.165);
--color-stone-550: oklch(0.4985 0.012 65.855);
--color-stone-650: oklch(0.409 0.0105 70.599);
--color-stone-750: oklch(0.321 0.0085 50.928);
--color-stone-850: oklch(0.242 0.0065 45.171);
}
@@ -26,13 +26,13 @@
click_function?: (e: MouseEvent) => void;
menu_options?: MenuOption[] | null;
menu_class?: string;
div_class?: string;
div_class?: string,
children?: any;
}>();
let menu_shown = $state(false);
let button_element: HTMLButtonElement;
let menu_element: HTMLDivElement | null = $state(null);
let menu_element: HTMLDivElement;
let position_bottom = $state(true);
function onclick(e: MouseEvent) {
@@ -47,7 +47,7 @@
}
function getPolygon(): [number, number][] | null {
if (!button_element || !menu_element) return null;
if (!button_element || !button_element) return null;
const b = button_element.getBoundingClientRect();
const m = menu_element.getBoundingClientRect();
@@ -154,7 +154,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-stone-800/45 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-black/20 border border-stone-400/10 shadow-xl/20 p-2 flex flex-col gap-2 text-stone-200 cursor-auto"
onclick={(e) => {
e.stopPropagation();
}}
@@ -12,137 +12,50 @@
TrafficCone
} from 'lucide-svelte';
import Button from './Button.svelte';
import PopUp from './PopUp.svelte';
import type { PopupContent } from '../ts/types';
import KeyInput from './KeyInput.svelte';
import { send_keyboard_input, show_blackscreen, show_html } from '../ts/api_handler';
import { run_on_all_selected_displays } from '../ts/stores/displays';
import { selected_display_ids } from '../ts/stores/select';
import { onMount } from 'svelte';
import TipTapInput from './TipTapInput.svelte';
let popup_content: PopupContent = $state({
open: false,
snippet: null,
title: '',
closable: true
});
function popup_close_function() {
popup_content.open = false;
}
const show_send_keys_popup = () => {
popup_content = {
open: true,
snippet: send_keys_popup,
title: 'Tastatur-Eingaben durchgeben',
title_icon: Keyboard,
closable: true
};
};
const show_text_popup = () => {
popup_content = {
open: true,
snippet: text_popup,
title: 'Text anzeigen',
title_icon: TextAlignStart,
closable: true,
window_class: 'size-full'
};
};
</script>
{#snippet send_keys_popup()}
<div class="overflow-hidden flex flex-col gap-2">
<div>
<KeyInput />
</div>
<div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={popup_close_function}>Fertig</Button>
</div>
</div>
{/snippet}
{#snippet text_popup()}
<TipTapInput />
{/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-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)"
className="px-9"
disabled={$selected_display_ids.length === 0}
click_function={() => {
run_on_all_selected_displays(send_keyboard_input, true, 'VK_LEFT');
}}><ArrowBigLeft /></Button
<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-9"
disabled={$selected_display_ids.length === 0}
click_function={() => {
run_on_all_selected_displays(send_keyboard_input, true, 'VK_RIGHT');
}}><ArrowBigRight /></Button
<Button title="Nächste Folie (Pfeil nach Rechts)" className="px-7"
><ArrowBigRight /></Button
>
</div>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={$selected_display_ids.length === 0}
click_function={show_text_popup}><TextAlignStart /> Text anzeigen</Button
>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={$selected_display_ids.length === 0}
click_function={() => {
run_on_all_selected_displays(show_blackscreen, true);
}}><Presentation />Blackout</Button
<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-65 justify-normal"
disabled={$selected_display_ids.length === 0}
<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-75 justify-normal"
disabled={$selected_display_ids.length === 0}
click_function={show_send_keys_popup}><Keyboard /> Tastatur-Eingaben durchgeben</Button
<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-75 justify-normal"
disabled={$selected_display_ids.length === 0}><Power /> PC hochfahren</Button
<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-75 justify-normal"
disabled={$selected_display_ids.length === 0}><PowerOff /> PC herunterfahren</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-75 justify-normal"
disabled={$selected_display_ids.length === 0}
<Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal"
><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>
@@ -1,11 +1,6 @@
<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 { 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';
@@ -21,10 +16,9 @@
import type { DisplayGroup, MenuOption } from '../ts/types';
import { selected_display_ids } from '../ts/stores/select';
let { display_group, get_display_menu_options, close_pinned_display } = $props<{
let { display_group, get_display_menu_options } = $props<{
display_group: DisplayGroup;
get_display_menu_options: (display_id: string) => MenuOption[];
close_pinned_display: () => void;
get_display_menu_options: (display_id: string) => MenuOption[]
}>();
let hovering_selectable = $state(false);
@@ -38,7 +32,7 @@
const { items, info } = e.detail;
if (items.length !== 1 && info.trigger === TRIGGERS.DRAG_STARTED) {
$is_display_drag = true;
$is_display_drag = true;
add_empty_display_group();
}
set_new_display_group_data(display_group.id, items);
@@ -46,7 +40,7 @@
function handle_finalize(e: CustomEvent) {
remove_empty_display_groups();
$is_display_drag = false;
$is_display_drag = false;
set_new_display_group_data(display_group.id, e.detail.items);
}
</script>
@@ -71,7 +65,7 @@
items: display_group.data,
type: 'item',
flipDurationMs: dnd_flip_duration_ms,
dropTargetStyle: { outline: 'none' }
dropTargetStyle: { outline: 'none' },
}}
onconsider={handle_consider}
onfinalize={handle_finalize}
@@ -83,7 +77,7 @@
class="outline-none"
role="figure"
>
<DisplayObject {display} {get_display_menu_options} {close_pinned_display} />
<DisplayObject {display} {get_display_menu_options} />
</section>
{/each}
@@ -10,30 +10,25 @@
import OnlineState from './OnlineState.svelte';
import type { Display, MenuOption } from '../ts/types';
import { is_selected, select, selected_display_ids } from '../ts/stores/select';
import { update_screenshot } from '../ts/stores/displays';
import { filter_file_selection_for_current_selected_displays } from '../ts/stores/files';
let { display, get_display_menu_options, close_pinned_display } = $props<{
let { display, get_display_menu_options } = $props<{
display: Display;
get_display_menu_options: (display_id: string) => MenuOption[];
close_pinned_display: () => void;
}>();
let hovering_unselectable = $state(false);
function onclick(e: Event) {
select(selected_display_ids, display.id);
filter_file_selection_for_current_selected_displays();
e.stopPropagation();
}
function on_preview_click(e: MouseEvent) {
if ($pinned_display_id === display.id) {
close_pinned_display();
$pinned_display_id = null;
} else {
$pinned_display_id = display.id;
}
update_screenshot(display.id);
e.stopPropagation();
}
</script>
+156 -196
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import { fade, scale, slide } from 'svelte/transition';
import {
all_displays_of_group_selected,
displays,
@@ -25,8 +25,6 @@
import { dragHandleZone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import DisplayGroupObject from './DisplayGroupObject.svelte';
import { Pane, Splitpanes } from 'svelte-splitpanes';
import HighlightedText from './HighlightedText.svelte';
let { handle_display_deletion, handle_display_editing } = $props<{
handle_display_deletion: (display_id: string) => void;
@@ -38,32 +36,21 @@
get_display_by_id($pinned_display_id || '', $displays)
);
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);
}
}
];
{
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[]) {
@@ -82,13 +69,6 @@
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;
@@ -105,176 +85,156 @@
<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>
{#if $pinned_display_id}
<!-- Pinned Item -->
<div in:fade={{ duration: 140 }} out:fade={{ duration: 120 }}>
<div
class="min-h-0 h-full grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl overflow-hidden"
class="grid grid-rows-[2.5rem_auto] will-change-[height,opacity] overflow-hidden rounded-2xl"
transition:slide={{ duration: 260, easing: cubicOut }}
>
<!-- 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
<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">
<button
class="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)}
<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)}
>
<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>
<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="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
<HighlightedText fg="text-stone-450" className="!p-1"><Settings class="inline pb-1" /></HighlightedText>
und
<HighlightedText fg="text-stone-450">Neuen Bildschirm hinzufügen</HighlightedText>.
</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}
{close_pinned_display}
/>
</section>
{/each}
{/if}
</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>
</Pane>
</Splitpanes>
</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>
+24 -289
View File
@@ -1,248 +1,16 @@
<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,
get_display_ids_where_file_is_missing,
get_display_ids_where_path_does_not_exist,
get_file_by_id,
get_longest_existing_path_and_needed_parts,
run_for_selected_files_on_selected_displays,
update_current_folder_on_selected_displays
} from '../ts/stores/files';
import { slide } from 'svelte/transition';
import FolderElementObject from './FolderElementObject.svelte';
import PopUp from './PopUp.svelte';
import type { PopupContent } from '../ts/types';
import TextInput from './TextInput.svelte';
import { is_valid_name } from '../ts/utils';
import { displays, get_display_by_id, run_on_all_selected_displays } from '../ts/stores/displays';
import { create_folders, delete_files, rename_file } from '../ts/api_handler';
import { get } from 'svelte/store';
import HighlightedText from './HighlightedText.svelte';
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";
let current_name: string = $state('');
let current_valid: boolean = $state(false);
let popup_content: PopupContent = $state({
open: false,
snippet: null,
title: '',
closable: true
});
function popup_close_function() {
popup_content.open = false;
}
async function create_new_folder() {
for (const display_id of $selected_display_ids) {
const display = get_display_by_id(display_id, $displays);
if (!display) continue;
const path_data = get_longest_existing_path_and_needed_parts(
$current_file_path,
display_id,
$all_files
);
await create_folders(display.ip, path_data.existing, [
...path_data.needed,
current_name.trim()
]);
}
await update_current_folder_on_selected_displays();
popup_close_function();
}
async function edit_file_name(new_file_name: string) {
await run_for_selected_files_on_selected_displays(async (ip: string, file_names: string[]) => {
if (file_names.length !== 1) {
console.log('EEEERRRRROOOOOOR', file_names);
return; // Error
}
await rename_file(ip, $current_file_path, file_names[0], new_file_name);
});
await update_current_folder_on_selected_displays();
popup_close_function();
}
const show_edit_file_popup = () => {
const file = get_file_by_id($selected_file_ids[0], $all_files, $current_file_path);
if (!file) return;
const is_folder = file.type === 'inode/directory';
const extension = 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: `${is_folder ? 'Ordner' : 'Datei'} umbenennen`,
title_icon: FolderPlus,
snippet_arg: extension,
closable: true
};
};
const show_new_folder_popup = () => {
current_name = '';
current_valid = false;
popup_content = {
open: true,
snippet: new_folder_popup,
title: 'Neuen Ordner erstellen',
title_icon: FolderPlus,
closable: true
};
};
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,
closable: true
};
};
</script>
{#snippet new_folder_popup()}
{#if get_display_ids_where_path_does_not_exist($current_file_path, $selected_display_ids, $all_files).length > 0}
<span class="leading-relaxed"
>Der aktuelle Pfad <HighlightedText
>{$current_file_path.slice(0, $current_file_path.length - 1)}</HighlightedText
> existiert nicht auf {get_display_ids_where_path_does_not_exist(
$current_file_path,
$selected_display_ids,
$all_files
).length === 1
? 'dem Bildschirm'
: 'den Bildschirmen'}
{#each get_display_ids_where_path_does_not_exist($current_file_path, $selected_display_ids, $all_files) as display_id, i}
{#if i !== 0}
,
{/if}
<HighlightedText>{get_display_by_id(display_id, $displays)?.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={(input: string) => {
if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen'];
const trimmed_input = input.trim();
if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge'];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if (
get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).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={(input: string) => {
if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen'];
const trimmed_input = input.trim() + extension;
if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge'];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if (
get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).some(
(e) =>
e.name === trimmed_input &&
e.id !== get_file_by_id($selected_file_ids[0], $all_files, $current_file_path)?.id
)
)
return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
enter_mode="submit"
enter_function={() => 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={() => 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-auto h-full min-h-0 grow-0">
{#each $selected_file_ids
.map((file_id) => get_file_by_id(file_id, $all_files, $current_file_path))
.filter((element) => element !== null) as file}
<FolderElementObject {file} not_interactable />
{/each}
</div>
</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={async () => {
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();
popup_close_function();
}}>Löschen</Button
>
</div>
{/snippet}
{#snippet clipboard_hover_snippet()}
<div></div>
{/snippet}
<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">
@@ -276,20 +44,14 @@
<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 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"
click_function={show_new_folder_popup}
disabled={$selected_display_ids.length === 0}><FolderPlus /></Button
className="px-3 flex"><FolderPlus /></Button
>
<div class="border border-stone-700 my-1"></div>
<Button
title="Datei(en) hochladen"
className="px-3 flex"
disabled={$selected_display_ids.length === 0}><Upload /></Button
>
<Button title="Datei(en) hochladen" className="px-3 flex"><Upload /></Button>
<Button
title="Ausgewählte Datei(en) herunterladen"
className="px-3 flex"
@@ -299,7 +61,6 @@
<Button
title="Aktuellen Ordner / Ausgewählte Datei(en) zwischen Bildschirmen synchronisieren"
className="px-3 flex gap-3"
disabled={$selected_display_ids.length === 0}
><RefreshCcw />
<span class="hidden 2xl:flex">Synchronisieren</span>
</Button>
@@ -310,18 +71,13 @@
className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Scissors /></Button
>
<Button
title="Ausgewählte Datei(en) einfügen"
className="!p-0 flex relative"
disabled={$selected_display_ids.length === 0}
<Button title="Ausgewählte Datei(en) einfügen" className="px-3 flex"
><ClipboardPaste /></Button
>
<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
@@ -329,43 +85,22 @@
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
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 max-w-full">
{#if $selected_display_ids.length === 0}
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center">
Es sind keine Bildschirme ausgewählt.
</span>
{:else}
{#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}
{#if get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).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 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
content={popup_content}
close_function={popup_close_function}
className="rounded-b-2xl"
snippet_container_class="overflow-hidden min-w-90"
/>
<!-- <PopUp title="Test" title_icon={Info}>
<div>Hier kann beliebiges HTML übergeben werden!</div>
</PopUp> -->
</div>
</div>
@@ -14,12 +14,7 @@
get_shifted_color
} from '../ts/stores/ui_behavior';
import Button from './Button.svelte';
import {
supported_file_type_icon,
type FolderElement,
type SupportedFileType
} from '../ts/types';
import { supported_file_types, type FolderElement, type SupportedFileType } from '../ts/types';
import {
is_selected,
select,
@@ -33,41 +28,31 @@
get_display_ids_where_file_is_missing
} from '../ts/stores/files';
import RefreshPlay from './RefreshPlay.svelte';
import { get_file_size_display_string, get_file_type } from '../ts/utils';
import { get_file_size_display_string } from '../ts/utils';
import { open_file } from '../ts/api_handler';
import {
displays,
get_display_by_id,
run_on_all_selected_displays,
update_screenshot
} from '../ts/stores/displays';
import { get_thumbnail_url } from '../ts/stores/thumbnails';
import { db } from '../ts/indexdb/file_thumbnails.db';
import { onDestroy, onMount } from 'svelte';
import { liveQuery } from 'dexie';
import { displays, get_display_by_id, update_screenshot } from '../ts/stores/displays';
let { file, not_interactable = false } = $props<{
file: FolderElement;
not_interactable?: boolean;
}>();
let thumbnail_url: string | null = $state(null);
// Update thumbnail_url automatically if data is available
const subscription = liveQuery(() => db.thumbnail_blobs.get(file.hash)).subscribe({
next: async () => {
thumbnail_url = await get_thumbnail_url(file.hash);
},
error: (err) => console.error('Dexie subscription error:', err)
});
onDestroy(() => subscription.unsubscribe());
let { file } = $props<{ file: FolderElement }>();
const is_folder = file.type === 'inode/directory';
function get_created_string(date_object: Date | null, full_string = false) {
if (!date_object) {
return full_string ? 'Verschiedene Daten auf verschiedenen Bildschirmen' : 'versch.';
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)
@@ -91,21 +76,18 @@
}
function get_grayed_out_text_color_strings(is_selected: boolean): string {
if (not_interactable) return 'text-stone-400';
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';
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) return;
select(selected_file_ids, file.id);
e.stopPropagation();
}
@@ -115,59 +97,63 @@
change_file_path($current_file_path + file.name + '/');
} else {
const path_to_file = $current_file_path + file.name;
await run_on_all_selected_displays(open_file, true, path_to_file);
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">
{#if !not_interactable}
<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>
{/if}
<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"
@@ -175,40 +161,28 @@
if (e.key === 'Enter' || e.key === ' ') onclick(e);
}}
{onclick}
class="{get_selectable_color_classes(
!not_interactable && is_selected(file.id, $selected_file_ids),
{
bg: true,
hover: !not_interactable,
active: !not_interactable,
text: true
}
)} {not_interactable
? 'rounded-lg'
: 'rounded-r-lg cursor-pointer'} transition-colors duration-200 gap-4 flex flex-row justify-between group w-full min-w-0"
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">
<div class="aspect-square rounded-md flex justify-center items-center p-2">
{#if is_folder}
<Folder class="size-full p-2" />
{:else if thumbnail_url}
<img
src={thumbnail_url}
alt="file_thumbnail"
class="object-contain size-full select-none block p-1 rounded-lg"
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" />
<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 class="size-full p-2" />
<FileIcon />
{/if}
</div>
<div class="content-center truncate select-none w-full" title={file.name}>
{file.name.includes('.') && !is_folder && get_file_type(file)
? file.name.slice(0, file.name.lastIndexOf('.'))
: file.name}
{file.name.includes('.') ? file.name.slice(0, file.name.lastIndexOf('.')) : file.name}
</div>
</div>
<div
@@ -216,7 +190,7 @@
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}
{#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"
@@ -242,7 +216,7 @@
>
<RefreshCcwDot class="size-full" />
</Button>
{/if} -->
{/if}
<div
class="w-14 content-center text-center select-none text-xs whitespace-nowrap"
title={get_created_string(file.date_created, true)}
@@ -1,12 +0,0 @@
<script lang="ts">
let {
children,
bg = 'bg-stone-750',
fg = 'text-stone-200',
className = ''
} = $props<{ children: any; bg?: string; fg?: string; className?: string }>();
</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>
@@ -1,70 +0,0 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { get_selectable_color_classes } from '../ts/stores/ui_behavior';
import key_map_json from './../../../../shared/keys.json';
import { fade } from 'svelte/transition';
import { selected_display_ids } from '../ts/stores/select';
import { displays, get_display_by_id, run_on_all_selected_displays } from '../ts/stores/displays';
import { send_keyboard_input } from '../ts/api_handler';
const bg = 'bg-stone-700';
const key_map: Record<string, string> = key_map_json as Record<string, string>;
let active = $state(false);
let last_keys: { id: number; key: string }[] = $state([]);
let el: HTMLDivElement;
function add_to_last_keys(name: string) {
const id = Date.now();
last_keys.push({ id, key: name });
setTimeout(() => {
last_keys = last_keys.filter((e) => e.id !== id);
}, 1500);
}
async function on_key_down(e: KeyboardEvent) {
if (!active) return;
const id = key_map[e.code];
if (!id) return;
e.preventDefault();
e.stopPropagation();
add_to_last_keys(e.code);
if (e.repeat) return;
await run_on_all_selected_displays(send_keyboard_input, true, id);
}
</script>
<div
role="textbox"
tabindex="0"
bind:this={el}
onclick={() => {
if (active) {
el.blur();
} else {
el.focus();
active = true;
}
}}
onblur={() => (active = false)}
onkeydown={on_key_down}
class="relative flex justify-center items-center h-15 w-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 class="absolute top-full left-0 ml-1 mt-0.5 flex flex-col-reverse text-sm text-stone-400">
{#each last_keys as key (key.id)}
<span animate:flip={{ duration: 200 }} in:fade={{ duration: 200 }} out:fade={{ duration: 500 }} >{key.key}</span>
{/each}
</div>
</div>
@@ -1,6 +1,5 @@
<script lang="ts">
import { type DisplayStatus } from "../ts/types";
import { display_status_to_info } from "../ts/utils";
import { display_status_to_info, type DisplayStatus } from "../ts/types";
let { selected, status, className = "" } = $props<{
selected: boolean;
+22 -33
View File
@@ -5,16 +5,9 @@
import type { PopupContent } from '../ts/types';
import { fade } from 'svelte/transition';
let {
content,
close_function,
className = '',
snippet_container_class = ''
} = $props<{
let { content, close_function } = $props<{
content: PopupContent;
close_function: () => void;
className?: string;
snippet_container_class?: string;
}>();
function try_to_close() {
@@ -38,41 +31,37 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="absolute inset-0 backdrop-blur flex justify-center items-center {className}"
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-[90%] max-h-[85%] flex flex-col shadow-2xl/60 overflow-hidden {content.window_class ??
''}"
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()}
>
{#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 flex-shrink-0 text-lg {content.title_class ??
''}"
>
{#if content.title_icon}
{@const Icon = content.title_icon}
<Icon strokeWidth="2" 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
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>
{/if}
<div class="p-2 min-h-0 overflow-auto flex flex-col gap-2 {snippet_container_class}">
<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>
@@ -2,7 +2,6 @@
import { fade } from 'svelte/transition';
import { get_shifted_color } from '../ts/stores/ui_behavior';
import { onMount } from 'svelte';
import { selected_display_ids } from '../ts/stores/select';
let {
current_value = $bindable(),
@@ -11,11 +10,7 @@
bg = 'bg-stone-750',
title,
placeholder = '',
is_valid_function = null,
focused_on_start = false,
extension = null,
enter_mode = 'none',
enter_function = null
is_valid_function = null
} = $props<{
current_value: string;
current_valid: boolean;
@@ -24,16 +19,11 @@
title: string;
placeholder?: string;
is_valid_function?: ((input: string) => [boolean, string]) | null;
focused_on_start?: boolean;
extension?: string | null;
enter_mode?: 'none' | 'focus_next' | 'submit';
enter_function?: (() => void) | null;
}>();
let focus_bg = get_shifted_color(bg, 100);
let focused: boolean = $state(false);
let focussed = $state(false);
let current_info = $state('');
let input_element: HTMLInputElement;
function validate_input() {
if (!is_valid_function) return;
@@ -43,92 +33,38 @@
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';
return 'focus:inset-ring-2 focus: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') return;
if (enter_mode === 'focus_next') {
event.preventDefault();
focus_next_element();
} else if (enter_mode === 'submit' && enter_function) {
event.preventDefault();
enter_function();
}
}
onMount(() => {
validate_input();
if (focused_on_start && input_element) input_element.focus();
selected_display_ids.subscribe(() => {
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="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 && focused}
<div
class={current_valid ? 'text-green-400' : 'text-red-400'}
transition:fade={{ duration: 100 }}
>
{#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:this={input_element}
<input
bind:value={current_value}
bind:focused
onkeydown={handle_keydown}
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}
/> -->
<!-- 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 flex-grow group"
{placeholder}
/>
{#if extension}
<span class="pr-3 text-stone-400 select-none pointer-events-none">
{extension}
</span>
{/if}
</div>
/>
</div>
@@ -1,256 +0,0 @@
<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,
Code,
Highlighter,
Italic,
PaintBucket,
QrCode,
Strikethrough
} from 'lucide-svelte';
import Button from './Button.svelte';
import { run_on_all_selected_displays } from '../ts/stores/displays';
import { show_html } from '../ts/api_handler';
import { get_selectable_color_classes } from '../ts/stores/ui_behavior';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-text-style';
import Highlight from '@tiptap/extension-highlight';
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: () => {},
is_selected: () => false,
title: 'QR-Code anfügen',
icon: QrCode
}
],
[
{
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()
};
}
function show_text() {
const html =
editor_state.editor?.getHTML() +
`<style>:root {--background-color: ${color_states.bg.value} !important;}
</style>`;
run_on_all_selected_displays(show_html, true, html);
}
onMount(() => {
editor_state.editor = new Editor({
element: element,
extensions: [
StarterKit,
Placeholder.configure({
placeholder: 'Text hier eingeben ...'
}),
TextStyle,
Color,
Highlight.configure({
multicolor: true
})
],
content: '',
onTransaction: ({ editor }) => {
// Increment the state signal to force a re-render
editor_state = { editor };
},
autofocus: true
});
});
onDestroy(() => {
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 class="flex flex-row gap-2 size-full">
<div
class="rounded-xl size-full flex-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;
}
+11 -29
View File
@@ -6,7 +6,7 @@
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 { display_status_to_info, type PopupContent } from '../ts/types';
import TextInput from '../components/TextInput.svelte';
import {
add_display,
@@ -21,8 +21,6 @@
import { ping_ip } from '../ts/api_handler';
import { onMount } from 'svelte';
import { on_start } from '../ts/main';
import { display_status_to_info } from '../ts/utils';
import HighlightedText from '../components/HighlightedText.svelte';
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))$/;
@@ -32,7 +30,6 @@
open: false,
snippet: null,
title: '',
title_class: '!text-xl',
closable: true
});
@@ -57,10 +54,10 @@
const mac = text_inputs_valid.mac.value === '' ? null : text_inputs_valid.mac.value;
const name = text_inputs_valid.name.value;
if (!!existing_display_id) {
await edit_display_data(existing_display_id, ip, mac, name);
edit_display_data(existing_display_id, ip, mac, name);
} else {
const status = await ping_ip(text_inputs_valid.ip.value);
await add_display(ip, mac, name, status);
add_display(ip, mac, name, status);
}
popup_close_function();
}
@@ -73,10 +70,9 @@
text_inputs_valid = text_inputs_valid_null_values;
popup_content = {
open: true,
snippet: display_popup,
snippet: add_new_display_popup,
title: 'Neuen Bildschirm hinzufügen',
title_icon: Monitor,
title_class: '!text-xl',
closable: true
};
};
@@ -87,7 +83,7 @@
snippet: remove_display_popup,
snippet_arg: display_id,
title: 'Bildschirm wirklich löschen?',
title_class: 'text-red-400 !text-xl',
title_class: 'text-red-400',
title_icon: Trash2,
closable: true
};
@@ -103,11 +99,10 @@
}
popup_content = {
open: true,
snippet: display_popup,
snippet: add_new_display_popup,
snippet_arg: display_id,
title: 'Bildschirm bearbeiten',
title_icon: Monitor,
title_class: '!text-xl',
closable: true
};
};
@@ -119,10 +114,9 @@
{#snippet remove_display_popup(display_id: string)}
<div class="max-w-prose px-2">
Soll der Bildschirm <HighlightedText
>{get_display_by_id(display_id, $displays)?.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.
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>
@@ -138,9 +132,8 @@
</div>
{/snippet}
{#snippet display_popup(existing_display_id: string | null = null)}
{#snippet add_new_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"
@@ -154,7 +147,6 @@
if (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
@@ -168,7 +160,6 @@
: [false, 'Ungültige IP-Adresse'];
}}
className="grow"
enter_mode="focus_next"
/>
<div class="flex items-end shrink-0">
<Button
@@ -198,10 +189,6 @@
? [true, 'Gültige MAC-Adresse']
: [false, 'Ungültige MAC-Adresse'];
}}
enter_mode="submit"
enter_function={() => {
finalize_add_edit_display(existing_display_id);
}}
/>
<div class="flex flex-row gap-2 justify-end pt-2">
{#if !!existing_display_id}
@@ -260,10 +247,5 @@
<FileView />
</div>
</div>
<PopUp
content={popup_content}
close_function={popup_close_function}
className="bg-white/10"
snippet_container_class="min-w-115"
/>
<PopUp content={popup_content} close_function={popup_close_function} />
</main>
-1
View File
@@ -201,7 +201,6 @@ module.exports = {
'group-active:border-stone-900',
'group-active:border-stone-950',
'h-10',
'h-15',
'h-20',
'h-25',
+52 -180
View File
@@ -1,65 +1,44 @@
import { notifications } from "./stores/notification";
import { to_display_status, type DisplayStatus, type FolderElement, type RequestResponse, type ShellCommandResponse, type TreeElement } from "./types";
import { to_display_status, type DisplayStatus, type FolderElement } from "./types";
import { get_uuid } from "./utils";
export async function get_screenshot(ip: string): Promise<Blob | null> {
export async function get_screenshot(ip: string) {
const options = { method: 'PATCH' };
const response = await request_display(ip, '/takeScreenshot', options);
if (!response.ok || !response.blob) return null;
return response.blob;
return await request_display(ip, '/takeScreenshot', options);
}
export async function open_file(ip: string, path_to_file: string): Promise<void> {
export async function open_file(ip: string, path_to_file: string): Promise<boolean> {
const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } };
await request_display(ip, `/file${path_to_file}`, options);
const raw_response = await request_display(ip, `/file${path_to_file}`, options);
return !!raw_response;
}
export async function send_keyboard_input(ip: string, key: string): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
key: key,
}),
};
await request_display(ip, '/keyboardInput', options);
}
export async function show_html(ip: string, html: string) {
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<FolderElement[] | null> {
export async function get_file_data(ip: string, path: string): Promise<FolderElement[]> {
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, true);
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;
const response: FileInfo[] = json_response.stdout.trim()
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_display(ip, '/shellCommand', options);
if (!raw_response) return [];
const response: FileInfo[] = raw_response.stdout.trim()
.split("\n")
.filter(Boolean)
.map((line: string) => JSON.parse(line) as FileInfo);
@@ -69,14 +48,10 @@ export async function get_file_data(ip: string, path: string): Promise<FolderEle
for (const response_element of response) {
// filter hidden files (start with '.' -> './.config')
if (response_element.name.charAt(2) !== '.') {
const truncated = {
...response_element,
created: response_element.created.slice(0, 16) // truncated to YYYY-MM-DD hh-mm -> no (milli)seconds
};
const folder_element: FolderElement = {
id: get_uuid(),
hash: JSON.stringify(truncated),
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),
@@ -88,158 +63,55 @@ export async function get_file_data(ip: string, path: string): Promise<FolderEle
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, true);
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;
return (JSON.parse(json_response.stdout.trim()) as [TreeElement, any])[0].contents || null;
}
export async function create_folders(ip: string, path: string, folder_names: string[]): Promise<void> {
let command = `cd ".${path}"`;
for (const part of folder_names) {
command += ` && mkdir "${part}" && cd "${part}/"`;
}
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: ``
})
};
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, `/file/preview${path_to_file}`, { method: 'GET' }, true, [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;
return raw_response.json.status ? to_display_status(raw_response.json.status) : null;
if (!raw_response) return null;
return raw_response.status ? to_display_status(raw_response.status) : null;
}
async function request_display(ip: string, api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }, log_in_debug: boolean = false, supress_error_handling_http_codes: number[] = []): Promise<RequestResponse> {
async function request_display(ip: string, api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<null | any> {
const url = `http://${ip}:1323/api${api_route}`;
return await request(url, options, log_in_debug, supress_error_handling_http_codes);
return await raw_request(url, options);
}
async function request_control(api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<RequestResponse> {
async function request_control(api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<null | any> {
const url = `${window.location.origin}/api${api_route}`;
return await request(url, options);
return await raw_request(url, options);
}
async function request(url: string, options: { method: string, headers?: Record<string, string>, body?: any }, log_in_debug: boolean = false, supress_error_handling_http_codes: number[] = []): Promise<RequestResponse> {
async function raw_request(url: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<null | any> {
try {
const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`;
if (log_in_debug) {
console.debug(url + cache_buster, options.body);
} else {
console.log(url + cache_buster, options.body);
}
console.log(url + cache_buster)
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: Object = await response.json();
request_response = { ok: response.ok, http_code: response.status, json: json };
}
if (log_in_debug) {
console.debug(request_response);
} else {
console.log(request_response);
}
return request_response;
if (!response.ok) {
console.error(`HTTP error! Status: ${response.status}`);
notifications.push("error", `HTTP-Fehler bei API-Anfrage`, `${url}\nHTTP-Status: ${response.status}`);
}
let error_description: string;
try {
const json: { description: string } = await response.json();
error_description = json.description;
} catch (error_on_json_parsing: any) {
error_description = `unknown error: ${error_on_json_parsing}`;
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
return await response.blob();
} else {
const json = await response.json();
if (json.error && json.error !== '') {
notifications.push("error", `Interner Fehler bei API-Anfrage`, `${url}\nJSON: ${JSON.stringify(json)}`);
}
return json;
}
console.error(url, error_description);
notifications.push("error", `Fehler ${response.status} bei API-Anfrage`, `${url}\nFehler: ${error_description}`);
} catch (error: any) {
if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) {
console.log("Request failed - Is the targeted device online?")
} else {
console.error(url, error);
notifications.push("error", `Fataler Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
console.log(typeof error, error instanceof TypeError, error.message);
console.error(error);
notifications.push("error", `Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
}
return null;
}
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)) {
console.log("current file_path does not exist on display:", ip);
return true;
}
console.error(shell_response);
notifications.push("error", `Fehler ${shell_response.exitCode} in API-Shell`, `${ip}\n${shell_command}\nFehler: ${shell_response.stderr}`);
return true;
}
if (shell_response.stdout.trim() === '') return true;
return false;
}
async function run_shell_command(ip: string, command: string, log_in_debug: boolean = false): Promise<RequestResponse> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: command
})
};
return await request_display(ip, '/shellCommand', options, log_in_debug);
}
@@ -1,20 +0,0 @@
import Dexie, { type EntityTable } from "dexie";
export interface ThumbnailBlobDBEntry {
hash: string;
blob: Blob;
}
export class FileDatabase extends Dexie {
thumbnail_blobs!: EntityTable<ThumbnailBlobDBEntry, "hash">; // (Type, PrimaryKey)
constructor() {
super("FileDatabase");
this.version(1).stores({
thumbnail_blobs: "++hash, blob" // primary key
});
}
}
export const db = new FileDatabase();
+7 -18
View File
@@ -1,34 +1,23 @@
import { get } from "svelte/store";
import { displays, run_on_all_selected_displays, update_displays_with_map, update_screenshot } from "./stores/displays"
import { displays, update_displays_with_map } from "./stores/displays"
import { ping_ip } from "./api_handler";
import type { Display } from "./types";
import { change_file_path, update_folder_elements_recursively } from "./stores/files";
import { db } from "./indexdb/file_thumbnails.db";
import { change_file_path, update_all_display_files } from "./stores/files";
const update_display_status_interval_seconds = 20;
export async function on_start() {
await db.thumbnail_blobs.clear();
await update_all_display_status();
await setInterval(update_all_display_status, update_display_status_interval_seconds * 1000);
export function on_start() {
update_all_display_status();
setInterval(update_all_display_status, update_display_status_interval_seconds * 1000);
update_all_display_files('/');
}
async function update_all_display_status() {
await update_displays_with_map(async (display: Display) => {
update_displays_with_map(async (display: Display) => {
const new_status = await ping_ip(display.ip);
if (new_status === null && display.status !== null) return display;
if (new_status === "app_online" && display.status !== "app_online") {
await on_display_start(display);
}
return { ...display, status: new_status, };
});
console.log("Display Status updated")
}
async function on_display_start(display: Display) {
await update_folder_elements_recursively(display, '/');
await update_screenshot(display.id);
}
+5 -25
View File
@@ -3,7 +3,7 @@ import type { Display, DisplayGroup, 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 { filter_file_selection_for_current_selected_displays } from "./files";
import DisplayGroupObject from "../../components/DisplayGroupObject.svelte";
export const displays: Writable<DisplayGroup[]> = writable<DisplayGroup[]>([{
id: get_uuid(),
@@ -25,16 +25,15 @@ export function add_display(ip: string, mac: string | null, name: string, status
});
}
export async function edit_display_data(display_id: string, ip: string, mac: string | null, name: string) {
await update_displays_with_map((display: Display) => {
export function edit_display_data(display_id: string, ip: string, mac: string | null, name: string) {
update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display;
return { ...display, ip: ip, mac: mac, name: name };
})
}
export function remove_display(display_id: string) {
select(selected_display_ids, display_id, false);
filter_file_selection_for_current_selected_displays();
console.log(display_id);
displays.update((displays: DisplayGroup[]) => {
displays = displays.map(display_group => ({
...display_group,
@@ -60,7 +59,6 @@ export function select_all_of_group(display_group: DisplayGroup, new_value: bool
for (const display of display_group.data) {
select(selected_display_ids, display.id, new_value);
}
filter_file_selection_for_current_selected_displays();
}
export function set_new_display_group_data(display_group_id: string, new_data: Display[]) {
@@ -113,13 +111,6 @@ export async function update_screenshot(display_id: string, check_type: "first_c
const display_ip = get_display_by_id(display_id, get(displays))?.ip;
if (!display_ip) return;
const new_blob = await get_screenshot(display_ip);
if (!new_blob) {
update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display;
return { ...display, preview_url: null, preview_timeout_id: null };
})
return;
}
const display = get_display_by_id(display_id, get(displays));
let update_needed = check_type === "first_check";
@@ -128,6 +119,7 @@ export async function update_screenshot(display_id: string, check_type: "first_c
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
}
}
@@ -164,18 +156,6 @@ export async function update_displays_with_map(update_function: (display: Displa
displays.set(updated_groups);
}
export async function run_on_all_selected_displays(run_function: ((ip: string, ...args: any[]) => void | Promise<void>), update_screenshot_afterwards: boolean, ...args: any[]) {
for (const display_id of get(selected_display_ids)) {
const display_ip = get_display_by_id(display_id, get(displays))?.ip;
if (display_ip) {
await run_function(display_ip, ...args)
if (update_screenshot_afterwards) {
await update_screenshot(display_id);
}
}
}
}
+41 -155
View File
@@ -1,11 +1,11 @@
import { get, writable, type Writable } from "svelte/store";
import type { Display, FolderElement, TreeElement } from "../types";
import { displays, get_display_by_id } from "./displays";
import { select, selected_display_ids, selected_file_ids } from "./select";
import { get_file_data, get_file_tree_data } from "../api_handler";
import type { Display, FolderElement } from "../types";
import { displays, update_displays_with_map } from "./displays";
import { selected_display_ids, selected_file_ids } from "./select";
import { get_file_data } from "../api_handler";
import { get_uuid } from "../utils";
import type { Folder } from "lucide-svelte";
import { notifications } from "./notification";
import { CirclePoundSterling } from "lucide-svelte";
import { deactivate_old_thumbnail_urls, generate_thumbnail } from "./thumbnails";
export const all_files: Writable<Record<string, Record<string, FolderElement[]>>> = writable<Record<string, Record<string, FolderElement[]>>>({});
// {
@@ -23,51 +23,18 @@ export const all_files: Writable<Record<string, Record<string, FolderElement[]>>
export const current_file_path: Writable<string> = writable<string>('/');
export async function change_file_path(new_path: string) {
export function change_file_path(new_path: string) {
current_file_path.update(() => {
return new_path;
});
selected_file_ids.update(() => {
return [];
})
deactivate_old_thumbnail_urls();
for (const display_group of get(displays)) {
for (const display of display_group.data) {
const changed_paths = await get_changed_directory_paths(display, new_path);
if (!changed_paths) continue;
console.log("Update file system from", display.name, ":", changed_paths);
for (const path of changed_paths) {
update_folder_elements_recursively(display, path);
}
}
}
}
export async function filter_file_selection_for_current_selected_displays() {
for (const selected_file_id of get(selected_file_ids)) {
if (!get_file_by_id(selected_file_id, get(all_files), get(current_file_path), true)) {
// file not found in selected displays
select(selected_file_ids, selected_file_id, false);
}
}
}
export async function update_current_folder_on_selected_displays() {
selected_file_ids.update(() => {
return [];
});
const current_path = get(current_file_path);
for (const display_id of get(selected_display_ids)) {
const display = get_display_by_id(display_id, get(displays));
if (!display) continue;
update_folder_elements_recursively(display, current_path);
}
setTimeout(async () => { await update_all_display_files(new_path) }, 0);
}
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 [selected_display_ids, []];
if (!all_files.hasOwnProperty(path)) return [];
const missing: string[] = [];
const colliding: string[] = [];
Display:
@@ -89,53 +56,16 @@ export function get_display_ids_where_file_is_missing(path: string, file: Folder
return [missing, colliding];
}
export function get_display_ids_where_path_does_not_exist(path: string, selected_display_ids: string[], all_files: Record<string, Record<string, FolderElement[]>>): string[] {
if (!all_files.hasOwnProperty(path)) return selected_display_ids;
const out: string[] = [];
for (const selected_display_id of selected_display_ids) {
if (!all_files[path].hasOwnProperty(selected_display_id)) {
out.push(selected_display_id);
export async function update_all_display_files(path: string) {
for (const display_group of get(displays)) {
for (const display of display_group.data) {
await update_folder_elements_recursively(display, path);
}
}
return out;
}
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 = get_recursive_changed_directory_paths(display, file_path, current_folder, get(all_files));
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)))));
}
function get_recursive_changed_directory_paths(display: Display, current_file_path: string, current_folder_elements: TreeElement[] | null, files: Record<string, Record<string, FolderElement[]>>): Set<string> {
const files_folder: FolderElement[] = files[current_file_path][display.id];
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
}
let 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 get_recursive_changed_directory_paths(display, new_file_path, tree_folder_element.contents, files)) {
has_changed.add(string);
}
}
}
return has_changed;
}
export async function update_folder_elements_recursively(display: Display, file_path: string = '/'): Promise<number> {
async function update_folder_elements_recursively(display: Display, file_path: string = '/'): Promise<number> {
const new_folder_elements = await get_file_data(display.ip, file_path);
if (new_folder_elements === null) return 0;
all_files.update((files: Record<string, Record<string, FolderElement[]>>) => {
if (!files.hasOwnProperty(file_path)) {
files[file_path] = {};
@@ -147,13 +77,6 @@ export async function update_folder_elements_recursively(display: Display, file_
const existing_folder_elements = files[file_path].hasOwnProperty(display.id) ? files[file_path][display.id] : [];
const diff = get_folder_elements_difference(existing_folder_elements, new_folder_elements);
// Generate Thumbnails:
setTimeout(async () => {
for (const folder_element of diff.new) {
await generate_thumbnail(display.ip, file_path, folder_element);
}
}, 0)
files[file_path][display.id].push(...diff.new);
return remove_folder_elements_recursively(files, display, diff.deleted, file_path);
})
@@ -214,6 +137,27 @@ function get_folder_elements_difference(old_elements: FolderElement[], new_eleme
// 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] = {};
// }
// 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)) return [];
@@ -224,21 +168,17 @@ export function get_current_folder_elements(all_files: Record<string, Record<str
FileOnDisplay:
for (const file_on_display of files_on_display_array[key]) {
for (const existing_file of files) {
const both_same_folder = file_on_display.type === "inode/directory" && existing_file.type === "inode/directory" && file_on_display.name === existing_file.name;
if (both_same_folder && file_on_display.size !== existing_file.size) {
existing_file.size = -1;
}
if (file_on_display.hash === existing_file.hash) {
continue FileOnDisplay;
} else if (both_same_folder) {
existing_file.date_created = null;
continue FileOnDisplay;
if (file_on_display.name === existing_file.name) {
if (file_on_display.hash === existing_file.hash) {
continue FileOnDisplay;
}
}
}
files.push({ ...file_on_display });
files.push(file_on_display);
}
}
}
return sort_files(files);
}
@@ -256,62 +196,8 @@ function sort_files(files: FolderElement[]) {
if (nameCompare !== 0) return nameCompare;
// Wenn name gleich, absteigend nach date_created
if (!b.date_created || !a.date_created) return -1;
return b.date_created.getTime() - a.date_created.getTime();
});
return files;
}
export function get_file_by_id(file_id: string, all_files: Record<string, Record<string, FolderElement[]>>, current_file_path: string, only_from_selected_displays: boolean = false): FolderElement | null {
let current_path_elements: Record<string, FolderElement[]> | undefined = all_files[current_file_path];
if (!current_path_elements) return null;
if (only_from_selected_displays) {
current_path_elements = Object.fromEntries(
Object.entries(current_path_elements).filter(([key]) => get(selected_file_ids).includes(key))
);
}
const all_folder_elements = Object.values(current_path_elements).flat();
const found = all_folder_elements.find(el => el.id === file_id);
if (!found) return null;
return found
}
export async function run_for_selected_files_on_selected_displays(action: (ip: string, file_names: string[]) => Promise<void>): Promise<void> {
const files = get(all_files);
const file_path = get(current_file_path);
const folder_elements: FolderElement[] = get(selected_file_ids)
.map((file_id) => get_file_by_id(file_id, files, file_path))
.filter((element) => element !== null);
const folder_element_hashs = folder_elements
.map((folder_element) => folder_element.hash)
.filter((hash) => hash !== null);
for (const display_id of get(selected_display_ids)) {
if (!files[file_path].hasOwnProperty(display_id)) continue;
const files_on_display = files[file_path][display_id];
const selected_file_names_on_display: string[] = files_on_display
.filter((e) => (e.hash && folder_element_hashs.includes(e.hash)) || (e.type === 'inode/directory' && folder_elements.some(e2 => e2.name === e.name && e2.type === 'inode/directory')))
.map((folder_element) => folder_element.name);
if (selected_file_names_on_display.length === 0) continue;
const display = get_display_by_id(display_id, get(displays));
if (!display) continue;
await action(display.ip, selected_file_names_on_display);
}
}
export function get_longest_existing_path_and_needed_parts(path: string, display_id: string, all_files: Record<string, Record<string, FolderElement[]>>): { existing: string; needed: string[] } {
const path_parts = path.slice(1, path.length - 1).split('/').filter(e => e.length !== 0); // remove front and back / and split after that, then remove empty strings
for (let i = path_parts.length; i > 0; i--) {
const current_path = '/' + [...path_parts].splice(0, i).join('/') + '/';;
if (all_files.hasOwnProperty(current_path)) {
if (all_files[current_path].hasOwnProperty(display_id)) {
return { existing: current_path, needed: [...path_parts].splice(i) };
}
}
}
return { existing: '/', needed: path_parts };
}
@@ -14,7 +14,7 @@ function createNotifications() {
function push(type: "error" | "success" | "info", title: string, message: string = "", className: string = "") {
const id = Date.now();
const duration = type === "error" ? 16000 : 4000;
const duration = type === "error" ? 8000 : 4000;
update((n) => [...n, { id, title, message, duration, className, type }]);
setTimeout(() => {
update((n) => n.filter((x) => x.id !== id));
@@ -1,38 +0,0 @@
import { get, writable, type Writable } from "svelte/store";
import { get_thumbnail_blob } from "../api_handler";
import { type FolderElement } from "../types";
import { db, type ThumbnailBlobDBEntry } from "../indexdb/file_thumbnails.db";
import { get_file_type } from "../utils";
export const active_thumbnail_urls: Writable<string[]> = writable<string[]>([]);
export async function generate_thumbnail(display_ip: string, path: string, folder_element: FolderElement): Promise<void> {
const supported_file_type = get_file_type(folder_element);
if (!supported_file_type || !folder_element.hash) return;
const hash: string = folder_element.hash;
if (await db.thumbnail_blobs.get(hash)) return;
const thumbnail_blob = await get_thumbnail_blob(display_ip, path + folder_element.name);
if (!thumbnail_blob) return;
await db.thumbnail_blobs.add({ hash: hash, blob: thumbnail_blob });
}
export async function get_thumbnail_url(hash: string | null): Promise<string | null> {
if (hash === null) return null;
const thumbnail_blob = await db.thumbnail_blobs.get(hash) as ThumbnailBlobDBEntry;
if (!thumbnail_blob) return null;
const new_url = URL.createObjectURL(thumbnail_blob.blob);
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 []; })
}
+59 -36
View File
@@ -1,46 +1,57 @@
import { FileBox, FileImage, FileText, FileVideoCamera, ImagePlay, type X } from "lucide-svelte";
import { FileBox, FileImage, FileVideoCamera, ImagePlay, type X } from "lucide-svelte";
import type { Snippet } from "svelte";
export type RequestResponse = {
ok: boolean,
http_code?: number
blob?: Blob,
json?: any,
}
export type ShellCommandResponse = {
stdout: string,
stderr: string,
exitCode: number,
}
export type SupportedFileType = {
display_name: string;
mime_type: string;
display_name:
string; mime_type:
string; icon: typeof X;
};
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 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;
id?: string;
hash: string | null;
thumbnail_url: string | null;
name: string;
type: string;
date_created: Date | null;
size: number;
}
export type TreeElement = {
contents?: TreeElement[];
type: "file" | "directory";
name: string;
date_created: Date;
size: number;
}
@@ -73,10 +84,9 @@ export type PopupContent = {
open: boolean;
snippet: Snippet<[string]> | null;
snippet_arg?: string;
title?: string;
title: string;
title_class?: string;
title_icon?: typeof X | null;
window_class?: string;
closable?: boolean;
}
@@ -86,4 +96,17 @@ export function to_display_status(value: string): DisplayStatus {
return ["host_offline", "app_offline", "app_online"].includes(value)
? (value as DisplayStatus)
: null;
}
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 '???';
}
}
+9 -49
View File
@@ -1,41 +1,19 @@
import type { DisplayStatus, FolderElement, 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>;
export 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;
}
export function get_uuid(): string {
return crypto.randomUUID();
return crypto.randomUUID();
}
export function get_file_size_display_string(size: number, toFixed: number | null = null): string {
if (size < 0) return toFixed === null ? "versch." : "Verschiedene Größen auf verschiedenen Bildschirmen";
if (size === 0) return "0 B";
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 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 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]}`;
const size_string = `${value.toFixed(toFixed !== null ? toFixed : Math.max(0, 2 - Math.floor(Math.log10(value))))} ${sizes[i]}`;
return size_string.replace('.', ',');
return size_string.replace('.', ',');
}
@@ -62,21 +40,3 @@ export async function image_content_hash(blob: Blob, size = 32): Promise<number>
return hash >>> 0; // unsigned int
}
export function is_valid_name(input: string): boolean {
return /^[\p{L}\p{N}\p{M}\-_.+,()[\]{}@!§$%&=~^ ]+$/u.test(input);
}
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 '???';
}
}
+1 -9
View File
@@ -2,24 +2,16 @@ module plg-mudics/control
go 1.24.5
require (
github.com/labstack/echo/v4 v4.13.4
github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a
)
require github.com/labstack/echo/v4 v4.13.4
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/sync v0.14.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
-27
View File
@@ -1,11 +1,5 @@
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/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -14,15 +8,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
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=
@@ -31,28 +16,16 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
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/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/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/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/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/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
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=
+4 -61
View File
@@ -4,37 +4,16 @@ 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()
// Serve the embedded SvelteKit frontend
@@ -46,25 +25,14 @@ 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"
// 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, false)
go shared.OpenBrowserWindow("http://localhost:"+port, false, false)
err := e.Start(":" + port)
if err != nil {
slog.Error("Failed to open browser window", "error", err)
os.Exit(1)
slog.Error("Failed to start Echo Webserver", "error", err)
}
}
@@ -93,28 +61,3 @@ 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 {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid MAC address"})
}
client, err := wol.NewClient()
if err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to create Wake-on-LAN client"})
}
if err := client.Wake("255.255.255.255", mac); err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to send Wake-on-LAN packet"})
}
return ctx.JSON(http.StatusOK, struct{}{})
}
-16
View File
@@ -1,16 +0,0 @@
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
@@ -1,22 +0,0 @@
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
}
-61
View File
@@ -1,61 +0,0 @@
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
}
+12 -98
View File
@@ -1,102 +1,16 @@
# API
# PLG-MuDiCS Display
All response and request bodies are `application/json` if not otherwise specified. If no response schema is specified an empty json object `{}` is returned.
## Runtime Requirements
## Default
- Bash
- Chromium
- LibreOffice (presentation view)
- ImageMagick (file preview)
- ffmpeg (file preview)
- Ghostscript (file preview)
### Responses
## Development Setup
#### 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
- `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.
- install Go (at least version v1.25)
- build template: `go generate ./..`
- run code: `go run .`
-2
View File
@@ -6,8 +6,6 @@ require (
github.com/a-h/templ v0.3.960
github.com/labstack/echo/v4 v4.13.4
github.com/micmonay/keybd_event v1.1.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/gabriel-vasile/mimetype v1.4.11
)
require (
-4
View File
@@ -14,8 +14,6 @@ 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=
@@ -32,8 +30,6 @@ 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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+1 -3
View File
@@ -24,10 +24,8 @@ func main() {
return
}
port := "1323"
// 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(VERSION, port)
err = shared.OpenBrowserWindow("http://localhost:"+port, true, true)
if err != nil {
slog.Error("Failed to open browser window", "error", err)
-63
View File
@@ -1,63 +0,0 @@
package pkg
import (
"fmt"
"os"
"os/exec"
"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":
fh.runningProgram = exec.Command("soffice", "--show", path, "--nologo", "--view", "--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 -6
View File
@@ -10,9 +10,8 @@ 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
@@ -37,7 +36,7 @@ func GenerateFilePreview(inputPath string) (string, error) {
}
func generateImagePreview(inputPath string, outputPath string) error {
cmd := exec.Command("magick", inputPath, "-gravity", "center", "-crop", "1:1", "-thumbnail", "100", "-quality", "50", outputPath)
cmd := exec.Command("magick", inputPath, "-thumbnail", "100x100", "-quality", "50", outputPath)
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
if result.ExitCode == 127 {
@@ -54,7 +53,7 @@ func generatePDFPreview(inputPath string, outputPath string) error {
return ErrFilePreviewToolsMissing
}
cmd := exec.Command("magick", fmt.Sprintf("%s[0]", inputPath), "-gravity", "center", "-crop", "1:1", "-thumbnail", "100x100", "-quality", "50", outputPath)
cmd := exec.Command("magick", fmt.Sprintf("%s[0]", inputPath), "-thumbnail", "100x100", "-quality", "50", outputPath)
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
if result.ExitCode == 127 {
@@ -83,4 +82,4 @@ func generateVideoPreview(inputPath string, outputPath string) error {
}
return nil
}
}
+16 -19
View File
@@ -16,6 +16,8 @@ import (
"github.com/micmonay/keybd_event"
)
func GetDeviceIp() (string, error) {
addrs, err := net.InterfaceAddrs()
if err != nil {
@@ -47,31 +49,24 @@ func GetDeviceMac() (string, error) {
return "", fmt.Errorf("no suitable MAC address found")
}
type KeyAction int
const (
KeyPress KeyAction = iota
KeyRelease
)
func KeyboardInput(key int, action KeyAction) error {
var err error
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)
switch action {
case KeyPress:
err = kb.Press()
case KeyRelease:
err = kb.Release()
}
if err != nil {
return fmt.Errorf("failed to run key event: %w", err)
if err := kb.Launching(); err != nil {
return fmt.Errorf("failed to launch key event: %w", err)
}
return nil
@@ -95,6 +90,7 @@ func TakeScreenshot() (string, error) {
return "", errors.New("no screenshot utility found or all failed")
}
func GetStoragePath() (string, error) {
var storagePath string
@@ -102,7 +98,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-mudics", "display")
storagePath = filepath.Join(home, ".local", "share", "plg-connect-display")
if err := os.MkdirAll(storagePath, os.ModePerm); err != nil {
return "", fmt.Errorf("failed to create local config directory: %w", err)
}
@@ -140,3 +136,4 @@ func ResolveStorageFilePath(pathParam string) (string, bool, error) {
return fullPath, true, nil
}
-12
View File
@@ -1,12 +0,0 @@
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")
+70 -140
View File
@@ -5,26 +5,37 @@ import (
"context"
"errors"
"fmt"
"image/color"
"io"
"log/slog"
"net"
"net/http"
"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/skip2/go-qrcode"
"github.com/micmonay/keybd_event"
"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
@@ -36,13 +47,6 @@ func StartWebServer(v string, port string) {
e.GET("/splash", func(ctx echo.Context) error {
return ctx.HTML(http.StatusOK, shared.SplashScreenTemplate)
})
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())
@@ -90,9 +94,8 @@ 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, showQR).Render(context.Background(), &status)
deviceInfoTemplate(ip, mac).Render(context.Background(), &status)
connectedEvent := Event{
Data: status.Bytes(),
}
@@ -121,36 +124,13 @@ 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)
if err != nil {
slog.Warn("Failed to validate file path", "path", pathParam, "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid file path"})
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid file path"})
}
ctx.Set("fullPath", fullPath)
ctx.Set("fileExists", exists)
@@ -164,14 +144,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, shared.ErrorResponse{Description: shared.BadRequestDescription})
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
}
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, shared.ErrorResponse{Description: "Failed to get storage path"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
}
cmd.Dir = storagePath
@@ -186,156 +166,117 @@ func shellCommandRoute(ctx echo.Context) error {
func keyboardInputRoute(ctx echo.Context) error {
var request struct {
Key string `json:"key"`
Action string `json:"action"`
Key string `json:"key"`
}
if err := ctx.Bind(&request); err != nil {
slog.Error("Failed to parse keyboard input", "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
}
if request.Action != "press" && request.Action != "release" {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
}
code, ok := pkg.KeyboardEvents[request.Key]
if !ok {
slog.Error("Unsupported key", "key", request.Key)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: fmt.Sprintf("Unsupported key: %s", request.Key)})
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported key: %s", request.Key)})
}
var action pkg.KeyAction
if request.Action == "press" {
action = pkg.KeyPress
}
if request.Action == "release" {
action = pkg.KeyRelease
}
err := pkg.KeyboardInput(code, action)
err := pkg.KeyboardInput(code)
if err != nil {
slog.Error("Failed to send keyboard input", "key", request.Key, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to send keyboard input"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to send keyboard input"})
}
slog.Info("Keyboard input sent", "key", request.Key)
return ctx.JSON(http.StatusOK, struct{}{})
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
}
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 {
slog.Error("Failed to create storage path", "error", err, "path", fullPath)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to prepare storage directory"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to prepare storage directory"})
}
if ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusConflict, shared.ErrorResponse{Description: "File already exists"})
return ctx.JSON(http.StatusConflict, ErrorResponse{Error: "File already exists"})
}
file, err := os.Create(fullPath)
if err != nil {
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"})
ext := strings.ToLower(filepath.Ext(fullPath))
if !supportedExtensions[ext] {
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported file extension: %s", ext)})
}
err = file.Sync() // ensure data is flushed to disk
data, err := io.ReadAll(ctx.Request().Body)
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"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to read file body"})
}
if err := os.WriteFile(fullPath, data, os.ModePerm); err != nil {
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to save file"})
}
slog.Info("File uploaded successfully", "path", fullPath)
return ctx.JSON(http.StatusOK, struct{}{})
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "File uploaded successfully"})
}
func downloadFileRoute(ctx echo.Context) error {
fullPath := ctx.Get("fullPath").(string)
if !ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
}
slog.Info("Serving file for download", "path", fullPath)
file, err := os.Open(fullPath)
err := ctx.File(fullPath)
if err != nil {
slog.Error("Failed to open file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to open file"})
slog.Error("Failed to serve file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
}
defer file.Close()
return ctx.Stream(http.StatusOK, "application/octet-stream", file)
slog.Info("File downloaded successfully", "path", fullPath)
return nil
}
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, shared.ErrorResponse{Description: "File not found"})
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
}
if sseConnection == nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Cant connect to display browser client"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "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, shared.ErrorResponse{Description: "Failed to reset view"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to reset view"})
}
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":
ext := strings.ToLower(filepath.Ext(fullPath))
switch ext {
case ".mp4":
var templateBuffer bytes.Buffer
videoTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String()
case "image/jpeg", "image/png", "image/gif":
case ".jpg", ".jpeg", ".png", ".gif":
var templateBuffer bytes.Buffer
imageTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String()
case "application/pdf", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation":
err = pkg.FileHandler.OpenFile(fullPath)
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"})
}
default:
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"})
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Unsupported file type"})
}
slog.Info("Successfully run file", "file", pathParam)
return ctx.JSON(http.StatusOK, struct{}{})
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
}
func showHTMLRoute(ctx echo.Context) error {
@@ -344,19 +285,19 @@ 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, shared.ErrorResponse{Description: shared.BadRequestDescription})
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
}
err := resetView()
if err != nil {
slog.Error("Failed to reset view", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to reset view"})
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.StatusOK, struct{}{})
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
}
func pingRoute(ctx echo.Context) error {
@@ -371,13 +312,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, shared.ErrorResponse{Description: "Failed to take screenshot"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
}
err = ctx.File(screenshotPath)
if err != nil {
slog.Error("Failed to serve file", "file", screenshotPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to serve file"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
}
return nil
@@ -387,19 +328,19 @@ func previewRoute(ctx echo.Context) error {
fullPath := ctx.Get("fullPath").(string)
exists := ctx.Get("fileExists").(bool)
if !exists {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "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.StatusUnsupportedMediaType, shared.ErrorResponse{Description: "File type not supported for preview"})
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "File type not supported for preview"})
}
if errors.Is(err, pkg.ErrFilePreviewToolsMissing) {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Required tools for file preview are missing"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Required tools for file preview are missing"})
}
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to generate preview"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to generate preview"})
}
return ctx.File(outputFilePath)
@@ -407,22 +348,11 @@ func previewRoute(ctx echo.Context) error {
// Reset previous file views so they dont collide with the new one
func resetView() error {
err := pkg.FileHandler.CloseRunningProgram()
err := pkg.KeyboardInput(keybd_event.VK_ESC)
if err != nil {
return fmt.Errorf("failed to close running program: %w", err)
return fmt.Errorf("failed to send ESC key: %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
}
+66 -105
View File
@@ -1,120 +1,81 @@
package web
templ indexTemplate() {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PLG Connect 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 */
margin: 0;
padding: 0;
overflow: hidden;
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';
}
video,
img,
iframe {
width: 100vw;
/* Viewport width */
height: 100vh;
/* Viewport height */
object-fit: contain;
}
p,
li {
font-size: var(--font-size);
line-height: 1.2;
text-wrap: balance;
max-width: 90vw;
word-break: break-word;
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) {
if (event.code === 'Space') {
event.preventDefault();
var video = document.querySelector('video');
if (video) {
if (video.paused) {
video.play();
} else {
video.pause();
}
<!DOCTYPE html>
<html lang="en">
<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>
<style>
body {
display: flex;
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%);
}
}
});
</script>
</head>
<bod>
<div hx-get="/splash" hx-trigger="load"></div>
<main hx-ext="sse" sse-connect="/sse" sse-swap="message"></main>
</bod>
</html>
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';
text-wrap: balance;
max-width: 90vw;
word-break: break-word;
display: block;
}
</style>
<script>
document.addEventListener('keydown', function(event) {
if (event.code === 'Space') {
event.preventDefault();
var video = document.querySelector('video');
if (video) {
if (video.paused) {
video.play();
} else {
video.pause();
}
}
}
});
</script>
</head>
<bod>
<div hx-get="/splash" hx-trigger="load"></div>
<main hx-ext="sse" sse-connect="/sse" sse-swap="message"></main>
</bod>
</html>
}
templ videoTemplate(path string) {
<video autoplay>
<source src={ "/api/file/" + path } type="video/mp4" />
</video>
<video autoplay>
<source src={ "/api/file/" + path } type="video/mp4"/>
</video>
}
templ imageTemplate(path string) {
<img src={ "/api/file/" + path } />
<img src={ "/api/file/" + path }/>
}
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;">
templ deviceInfoTemplate(ip string, mac string) {
<p>
{ ip }
<br/>
<span style="text-transform: uppercase;">{ mac }</span>
</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>
}
</p>
}
-1
View File
@@ -1 +0,0 @@
(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
-153
View File
@@ -1,153 +0,0 @@
{
config,
pkgs,
...
}: {
imports = [
./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
#rustdesk
ungoogled-chromium
xfce.thunar-archive-plugin
git
nushell
unzip
iputils
xreader
# Libraries
imagemagick
ffmpeg
ghostscript
];
systemd.services.update-mudics = {
wantedBy = ["multi-user.target"];
after = ["network-online.target"];
wants = ["network-online.target"];
path = config.environment.systemPackages;
script = "nu ${./update.sh}";
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";
};
};
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.tmpfiles.rules = [
"d /home/mudics/mudics 0755 mudics - -"
];
networking.firewall = {
enable = true;
allowedTCPPorts = [
1323 # display
8080 # control
];
};
}
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1761999846,
"narHash": "sha256-IYlYnp4O4dzEpL77BD/lj5NnJy2J8qbHkNSFiPBCbqo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3de8f8d73e35724bf9abef41f1bdbedda1e14a31",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-21
View File
@@ -1,21 +0,0 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
};
outputs = {
self,
nixpkgs,
...
} @ inputs: {
nixosConfigurations.mudics = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
inherit inputs;
};
modules = [
./configuration.nix
];
};
};
}
-62
View File
@@ -1,62 +0,0 @@
#!/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
}
}
-114
View File
@@ -1,114 +0,0 @@
{
"Escape": "VK_ESC",
"Digit1": "VK_1",
"Digit2": "VK_2",
"Digit3": "VK_3",
"Digit4": "VK_4",
"Digit5": "VK_5",
"Digit6": "VK_6",
"Digit7": "VK_7",
"Digit8": "VK_8",
"Digit9": "VK_9",
"Digit0": "VK_0",
"KeyQ": "VK_Q",
"KeyW": "VK_W",
"KeyE": "VK_E",
"KeyR": "VK_R",
"KeyT": "VK_T",
"KeyY": "VK_Y",
"KeyU": "VK_U",
"KeyI": "VK_I",
"KeyO": "VK_O",
"KeyP": "VK_P",
"KeyA": "VK_A",
"KeyS": "VK_S",
"KeyD": "VK_D",
"KeyF": "VK_F",
"KeyG": "VK_G",
"KeyH": "VK_H",
"KeyJ": "VK_J",
"KeyK": "VK_K",
"KeyL": "VK_L",
"KeyZ": "VK_Z",
"KeyX": "VK_X",
"KeyC": "VK_C",
"KeyV": "VK_V",
"KeyB": "VK_B",
"KeyN": "VK_N",
"KeyM": "VK_M",
"F1": "VK_F1",
"F2": "VK_F2",
"F3": "VK_F3",
"F4": "VK_F4",
"F5": "VK_F5",
"F6": "VK_F6",
"F7": "VK_F7",
"F8": "VK_F8",
"F9": "VK_F9",
"F10": "VK_F10",
"F11": "VK_F11",
"F12": "VK_F12",
"F13": "VK_F13",
"F14": "VK_F14",
"F15": "VK_F15",
"F16": "VK_F16",
"F17": "VK_F17",
"F18": "VK_F18",
"F19": "VK_F19",
"F20": "VK_F20",
"F21": "VK_F21",
"F22": "VK_F22",
"F23": "VK_F23",
"F24": "VK_F24",
"NumLock": "VK_NUMLOCK",
"ScrollLock": "VK_SCROLLLOCK",
"CapsLock": "VK_CAPSLOCK",
"Minus": "VK_SP2",
"Equal": "VK_SP3",
"Backspace": "VK_BACKSPACE",
"Tab": "VK_TAB",
"BracketLeft": "VK_SP4",
"BracketRight": "VK_SP5",
"Enter": "VK_ENTER",
"Semicolon": "VK_SP6",
"Quote": "VK_SP7",
"Backquote": "VK_SP1",
"Backslash": "VK_SP8",
"Comma": "VK_SP9",
"Period": "VK_SP10",
"Slash": "VK_SP11",
"IntlBackslash": "VK_SP12",
"NumpadMultiply": "VK_KPASTERISK",
"NumpadDivide": "VK_KPSLASH",
"NumpadAdd": "VK_KPPLUS",
"NumpadSubtract": "VK_KPMINUS",
"NumpadDecimal": "VK_KPDOT",
"NumpadEnter": "VK_KPENTER",
"Space": "VK_SPACE",
"Numpad0": "VK_KP0",
"Numpad1": "VK_KP1",
"Numpad2": "VK_KP2",
"Numpad3": "VK_KP3",
"Numpad4": "VK_KP4",
"Numpad5": "VK_KP5",
"Numpad6": "VK_KP6",
"Numpad7": "VK_KP7",
"Numpad8": "VK_KP8",
"Numpad9": "VK_KP9",
"PageUp": "VK_PAGEUP",
"PageDown": "VK_PAGEDOWN",
"End": "VK_END",
"Home": "VK_HOME",
"ArrowLeft": "VK_LEFT",
"ArrowUp": "VK_UP",
"ArrowRight": "VK_RIGHT",
"ArrowDown": "VK_DOWN",
"PrintScreen": "VK_PRINT",
"Insert": "VK_INSERT",
"Delete": "VK_DELETE",
"Help": "VK_HELP",
"BrowserBack": "VK_BACK",
"Pause": "VK_PAUSE",
"Lang1": "VK_HANGUEL",
"Lang2": "VK_HANJA"
}
+1 -35
View File
@@ -2,10 +2,8 @@ package shared
import (
"bytes"
"context"
"errors"
"os/exec"
"time"
)
type CommandResponse struct {
@@ -14,11 +12,7 @@ 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
@@ -26,34 +20,6 @@ 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(),
@@ -69,4 +35,4 @@ func parseCmdResult(cmd *exec.Cmd, stdout, stderr bytes.Buffer, err error) Comma
}
return commandOutput
}
}
+30 -18
View File
@@ -40,7 +40,15 @@
<text class="splash-text" x="850" y="66.25" font-size="90">S</text>
<!-- Controller -->
<rect x="325" y="350" width="300" height="168.75" rx="8" ry="8" class="splash-control-monitor" />
<rect
x="325"
y="350"
width="300"
height="168.75"
rx="8"
ry="8"
class="splash-control-monitor"
/>
<rect x="335" y="390" width="135" height="120" rx="8" ry="8" class="splash-other-button" />
<rect x="480" y="390" width="135" height="55" rx="8" ry="8" class="splash-other-button" />
<rect x="480" y="455" width="135" height="55" rx="8" ry="8" class="splash-start-button" />
@@ -51,15 +59,30 @@
<path class="window-controls" fill="none" d="M 575 360 H 590 V 375 H 575 V 360" />
<path class="window-controls" d="M 550 375 H 565" />
<svg class="cursor" width="50" height="50" viewBox="0 0 24 24" x="510" y="480" aria-hidden="true">
<g stroke="#ffffff" fill="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg
class="cursor"
width="50"
height="50"
viewBox="0 0 24 24"
x="510"
y="480"
aria-hidden="true"
>
<g
stroke="#ffffff"
fill="#000000"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="#000000" d="M 6.5 10 H 18 V 19 H 6" />
<path d="M22 14a8 8 0 0 1-8 8" />
<path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2" />
<path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1" />
<path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10" />
<path
d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" />
d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"
/>
</g>
</svg>
</svg>
@@ -67,9 +90,6 @@
<style>
:root {
--splash-fade-out-delay: 0s;
--splash-fade-out-state: running;
--base-stroke: #444444;
--base-stroke-active: #109e00;
@@ -98,20 +118,13 @@
align-items: center;
justify-content: center;
z-index: 9999;
/* animation: splash-fade 1s ease 4s forwards; */
animation: splash-fade 1s ease 4s forwards;
height: 100vh;
width: 100vw;
animation-name: splash-fade;
animation-duration: 1s;
animation-timing-function: ease;
animation-fill-mode: forwards;
animation-delay: var(--splash-fade-out-delay);
animation-play-state: var(--splash-fade-out-state);
}
#splash-svg {
max-height: 50vh;
max-height: 40vh;
max-width: 80vw;
opacity: 0;
animation: fade-in 1s ease 500ms forwards;
@@ -170,7 +183,6 @@
dominant-baseline: middle;
fill: var(--monitor-dark);
user-select: none;
font-family: sans-serif;
}
.splash-monitor {
@@ -264,4 +276,4 @@
.cursor * {
vector-effect: non-scaling-stroke;
}
</style>
</style>
-34
View File
@@ -1,34 +0,0 @@
{
".mp4": {
"display_name": "MP4",
"mime_type": "video/mp4"
},
".jpg": {
"display_name": "JPG",
"mime_type": "image/jpg"
},
".jpeg": {
"display_name": "JPG",
"mime_type": "image/jpeg"
},
".png": {
"display_name": "PNG",
"mime_type": "image/png"
},
".gif": {
"display_name": "GIF",
"mime_type": "image/gif"
},
".pptx": {
"display_name": "PPTX",
"mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
},
".odp": {
"display_name": "ODP",
"mime_type": "application/vnd.oasis.opendocument.presentation"
},
".pdf": {
"display_name": "PDF",
"mime_type": "application/pdf"
}
}