refactor(frontend): use db for files & displays

Co-Authored-By: E44 <129310925+programmer-44@users.noreply.github.com>
This commit is contained in:
2025-12-29 16:28:26 +01:00
parent 880725a105
commit c6b0c1cf31
30 changed files with 1258 additions and 1089 deletions
-18
View File
@@ -1506,7 +1506,6 @@
"integrity": "sha512-zN2yzBc2dIES2BSzLhNP2weYhwB77kgM/oAktICZVmmljyEmPZrlUwr14jjdK9/eDu7WdAuf6gTdYIJLTcN3Fw==", "integrity": "sha512-zN2yzBc2dIES2BSzLhNP2weYhwB77kgM/oAktICZVmmljyEmPZrlUwr14jjdK9/eDu7WdAuf6gTdYIJLTcN3Fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
@@ -1546,7 +1545,6 @@
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"debug": "^4.4.1", "debug": "^4.4.1",
@@ -1903,7 +1901,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.0.tgz",
"integrity": "sha512-kmS7ZVpHm1EMnW1Wmft9H5ZLM7E0G0NGBx+aGEHGDcNxZBXD2ZUa76CuWjIhOGpwsPbELp684ZdpF2JWoNi4Dg==", "integrity": "sha512-kmS7ZVpHm1EMnW1Wmft9H5ZLM7E0G0NGBx+aGEHGDcNxZBXD2ZUa76CuWjIhOGpwsPbELp684ZdpF2JWoNi4Dg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
@@ -2105,7 +2102,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.0.tgz",
"integrity": "sha512-4Ane7VCVZ+GFOQNuy2nMP+SoWH7EemC3geTTqvgHm1H0tbSosxLJAVaZ9dF06F35RJmYCm+jLJUhRVd156eCRQ==", "integrity": "sha512-4Ane7VCVZ+GFOQNuy2nMP+SoWH7EemC3geTTqvgHm1H0tbSosxLJAVaZ9dF06F35RJmYCm+jLJUhRVd156eCRQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
@@ -2238,7 +2234,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.0.tgz",
"integrity": "sha512-g43beA73ZMLezez1st9LEwYrRHZ0FLzlsSlOZKk7sdmtHLmuqWHf4oyb0XAHol1HZIdGv104rYaGNgmQXr1ecQ==", "integrity": "sha512-g43beA73ZMLezez1st9LEwYrRHZ0FLzlsSlOZKk7sdmtHLmuqWHf4oyb0XAHol1HZIdGv104rYaGNgmQXr1ecQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
@@ -2253,7 +2248,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.0.tgz",
"integrity": "sha512-plCQDLCZIOc92cizB8NNhBRN0szvYR3cx9i5IXo6v9Xsgcun8KHNcJkesc2AyeqdIs0BtOJZaqQ9adHThz8UDw==", "integrity": "sha512-plCQDLCZIOc92cizB8NNhBRN0szvYR3cx9i5IXo6v9Xsgcun8KHNcJkesc2AyeqdIs0BtOJZaqQ9adHThz8UDw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-changeset": "^2.3.0", "prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1", "prosemirror-collab": "^1.3.1",
@@ -2403,7 +2397,6 @@
"integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/scope-manager": "8.46.0",
"@typescript-eslint/types": "8.46.0", "@typescript-eslint/types": "8.46.0",
@@ -2621,7 +2614,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3018,7 +3010,6 @@
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -4198,7 +4189,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -4226,7 +4216,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -4346,7 +4335,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -4480,7 +4468,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"orderedmap": "^2.0.0" "orderedmap": "^2.0.0"
} }
@@ -4510,7 +4497,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.0.0", "prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0", "prosemirror-transform": "^1.0.0",
@@ -4559,7 +4545,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz",
"integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==", "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.20.0", "prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0", "prosemirror-state": "^1.0.0",
@@ -5222,7 +5207,6 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.39.11.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.39.11.tgz",
"integrity": "sha512-8MxWVm2+3YwrFbPaxOlT1bbMi6OTenrAgks6soZfiaS8Fptk4EVyRIFhJc3RpO264EeSNwgjWAdki0ufg4zkGw==", "integrity": "sha512-8MxWVm2+3YwrFbPaxOlT1bbMi6OTenrAgks6soZfiaS8Fptk4EVyRIFhJc3RpO264EeSNwgjWAdki0ufg4zkGw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -5472,7 +5456,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -5541,7 +5524,6 @@
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -16,7 +16,7 @@
menu_class = 'right-0', menu_class = 'right-0',
div_class = '', div_class = '',
children children
} = $props<{ }: {
className?: string; className?: string;
bg?: string; bg?: string;
hover_bg?: string; hover_bg?: string;
@@ -28,7 +28,7 @@
menu_class?: string; menu_class?: string;
div_class?: string; div_class?: string;
children?: any; children?: any;
}>(); } = $props();
let menu_shown = $state(false); let menu_shown = $state(false);
let button_element: HTMLButtonElement; let button_element: HTMLButtonElement;
@@ -166,8 +166,8 @@
? 'text-stone-500 cursor-not-allowed' ? 'text-stone-500 cursor-not-allowed'
: 'hover:bg-white/35 active:bg-white/60 cursor-pointer ' + : 'hover:bg-white/35 active:bg-white/60 cursor-pointer ' +
option.class} rounded-lg p-2 transition-colors duration-200 select-none flex flex-row gap-2 items-center" option.class} rounded-lg p-2 transition-colors duration-200 select-none flex flex-row gap-2 items-center"
onclick={(e) => { onclick={async (e) => {
if (option.on_select) option.on_select(); if (option.on_select) await option.on_select();
close_menu(); close_menu();
}} }}
> >
@@ -15,7 +15,7 @@
import PopUp from './PopUp.svelte'; import PopUp from './PopUp.svelte';
import type { PopupContent } from '../ts/types'; import type { PopupContent } from '../ts/types';
import KeyInput from './KeyInput.svelte'; import KeyInput from './KeyInput.svelte';
import { send_keyboard_input, show_blackscreen, show_html } from '../ts/api_handler'; import { send_keyboard_input, show_blackscreen } from '../ts/api_handler';
import { run_on_all_selected_displays } from '../ts/stores/displays'; import { run_on_all_selected_displays } from '../ts/stores/displays';
import { selected_display_ids } from '../ts/stores/select'; import { selected_display_ids } from '../ts/stores/select';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -81,16 +81,16 @@
title="Vorherige Folie (Pfeil nach Links)" title="Vorherige Folie (Pfeil nach Links)"
className="px-9" className="px-9"
disabled={$selected_display_ids.length === 0} disabled={$selected_display_ids.length === 0}
click_function={() => { click_function={async () => {
run_on_all_selected_displays(send_keyboard_input, true, 'VK_LEFT'); await run_on_all_selected_displays(send_keyboard_input, true, 'VK_LEFT');
}}><ArrowBigLeft /></Button }}><ArrowBigLeft /></Button
> >
<Button <Button
title="Nächste Folie (Pfeil nach Rechts)" title="Nächste Folie (Pfeil nach Rechts)"
className="px-9" className="px-9"
disabled={$selected_display_ids.length === 0} disabled={$selected_display_ids.length === 0}
click_function={() => { click_function={async () => {
run_on_all_selected_displays(send_keyboard_input, true, 'VK_RIGHT'); await run_on_all_selected_displays(send_keyboard_input, true, 'VK_RIGHT');
}}><ArrowBigRight /></Button }}><ArrowBigRight /></Button
> >
</div> </div>
@@ -102,8 +102,8 @@
<Button <Button
className="px-3 flex gap-3 w-75 justify-normal" className="px-3 flex gap-3 w-75 justify-normal"
disabled={$selected_display_ids.length === 0} disabled={$selected_display_ids.length === 0}
click_function={() => { click_function={async () => {
run_on_all_selected_displays(show_blackscreen, true); await run_on_all_selected_displays(show_blackscreen, true);
}}><Presentation />Blackout</Button }}><Presentation />Blackout</Button
> >
<div class="flex flex-row justify-normal"> <div class="flex flex-row justify-normal">
@@ -2,10 +2,10 @@
import { GripHorizontal } from 'lucide-svelte'; import { GripHorizontal } from 'lucide-svelte';
import { dragHandle } from 'svelte-dnd-action'; import { dragHandle } from 'svelte-dnd-action';
let { bg, className = "" } = $props<{ let { bg, className = "" }: {
bg: string; bg: string;
className?: string; className?: string;
}>(); } = $props();
</script> </script>
<div <div
@@ -10,51 +10,61 @@
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import DisplayObject from './DisplayObject.svelte'; import DisplayObject from './DisplayObject.svelte';
import { import {
add_empty_display_group,
all_displays_of_group_selected, all_displays_of_group_selected,
remove_empty_display_groups, get_display_ids_in_group,
select_all_of_group, select_all_of_group,
set_new_display_group_data set_new_display_order
} from '../ts/stores/displays'; } from '../ts/stores/displays';
import DNDGrip from './DNDGrip.svelte'; import DNDGrip from './DNDGrip.svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { DisplayGroup, MenuOption } from '../ts/types'; import type { DisplayGroup, MenuOption } from '../ts/types';
import { selected_display_ids } from '../ts/stores/select'; import { selected_display_ids } from '../ts/stores/select';
import { liveQuery } from 'dexie';
import { onMount } from 'svelte';
let { display_group, get_display_menu_options, close_pinned_display } = $props<{ let { display_group_id, get_display_menu_options, close_pinned_display }: {
display_group: DisplayGroup;
display_group_id: string;
get_display_menu_options: (display_id: string) => MenuOption[]; get_display_menu_options: (display_id: string) => MenuOption[];
close_pinned_display: () => void; close_pinned_display: () => void;
}>(); } = $props
();
let all_selected = liveQuery(() =>
all_displays_of_group_selected(display_group_id, $selected_display_ids)
);
let display_ids_in_group = liveQuery(() => get_display_ids_in_group(display_group_id));
let hovering_selectable = $state(false); let hovering_selectable = $state(false);
function select_all_of_this_group() {
const new_value = !all_displays_of_group_selected(display_group, $selected_display_ids); async function select_all_of_this_group() {
select_all_of_group(display_group, new_value); const new_value = !(await all_displays_of_group_selected(
display_group_id,
$selected_display_ids
));
await select_all_of_group(display_group_id, new_value);
} }
function handle_consider(e: CustomEvent) { async function handle_consider(e: CustomEvent) {
const { items, info } = e.detail; const { items, info } = e.detail;
if (items.length !== 1 && info.trigger === TRIGGERS.DRAG_STARTED) { if (items.length !== 1 && info.trigger === TRIGGERS.DRAG_STARTED) {
$is_display_drag = true; $is_display_drag = true;
add_empty_display_group(); // add_empty_display_group();
} }
set_new_display_group_data(display_group.id, items); await set_new_display_order(items);
} }
function handle_finalize(e: CustomEvent) { async 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); await set_new_display_order(e.detail.items);
} }
</script> </script>
<div <div
transition:fade={{ duration: 100 }} transition:fade={{ duration: 100 }}
class="{get_selectable_color_classes( class="{get_selectable_color_classes(
all_displays_of_group_selected(display_group, $selected_display_ids), $all_selected || false,
{ {
bg: true, bg: true,
hover: hovering_selectable, hover: hovering_selectable,
@@ -68,7 +78,7 @@
<div <div
class="flex flex-col min-w-0 pl-2 py-2 gap-2 w-full" class="flex flex-col min-w-0 pl-2 py-2 gap-2 w-full"
use:dragHandleZone={{ use:dragHandleZone={{
items: display_group.data, items: $display_ids_in_group || [],
type: 'item', type: 'item',
flipDurationMs: dnd_flip_duration_ms, flipDurationMs: dnd_flip_duration_ms,
dropTargetStyle: { outline: 'none' } dropTargetStyle: { outline: 'none' }
@@ -76,7 +86,7 @@
onconsider={handle_consider} onconsider={handle_consider}
onfinalize={handle_finalize} onfinalize={handle_finalize}
> >
{#each display_group.data as display (display.id)} {#each $display_ids_in_group || [] as display (display.id)}
<!-- Each Group --> <!-- Each Group -->
<section <section
animate:flip={{ duration: $is_group_drag ? 0 : dnd_flip_duration_ms, easing: cubicOut }} animate:flip={{ duration: $is_group_drag ? 0 : dnd_flip_duration_ms, easing: cubicOut }}
@@ -87,7 +97,7 @@
</section> </section>
{/each} {/each}
{#if display_group.data.length === 0} {#if ($display_ids_in_group || []).length === 0}
<div class="min-h-10 h-full w-full pl-2 py-2 flex justify-center items-center"> <div class="min-h-10 h-full w-full pl-2 py-2 flex justify-center items-center">
Hier in leere neue Gruppe ablegen Hier in leere neue Gruppe ablegen
</div> </div>
@@ -95,18 +105,18 @@
</div> </div>
<div <div
class="flex items-center justify-center px-2" class="flex items-center justify-center px-2"
onclick={(e) => select_all_of_this_group()} onclick={select_all_of_this_group}
role="button" role="button"
tabindex="0" tabindex="0"
onkeydown={(e) => { onkeydown={async (e) => {
if (e.key === 'Enter' || e.key === ' ') select_all_of_this_group(); if (e.key === 'Enter' || e.key === ' ') await select_all_of_this_group();
}} }}
onmouseenter={() => (hovering_selectable = true)} onmouseenter={() => (hovering_selectable = true)}
onmouseleave={() => (hovering_selectable = false)} onmouseleave={() => (hovering_selectable = false)}
> >
<DNDGrip <DNDGrip
bg={get_selectable_color_classes( bg={get_selectable_color_classes(
all_displays_of_group_selected(display_group, $selected_display_ids), $all_selected || false,
{ {
bg: true bg: true
}, },
+17 -11
View File
@@ -10,30 +10,36 @@
import OnlineState from './OnlineState.svelte'; import OnlineState from './OnlineState.svelte';
import type { Display, MenuOption } from '../ts/types'; import type { Display, MenuOption } from '../ts/types';
import { is_selected, select, selected_display_ids } from '../ts/stores/select'; import { is_selected, select, selected_display_ids } from '../ts/stores/select';
import { update_screenshot } from '../ts/stores/displays'; import { screenshot_loop } from '../ts/stores/displays';
import { filter_file_selection_for_current_selected_displays } from '../ts/stores/files'; import { change_file_path, current_file_path } from '../ts/stores/files';
let { display, get_display_menu_options, close_pinned_display } = $props<{ let {
display,
get_display_menu_options,
close_pinned_display
}: {
display: Display; display: Display;
get_display_menu_options: (display_id: string) => MenuOption[]; get_display_menu_options: (display_id: string) => MenuOption[];
close_pinned_display: () => void; close_pinned_display: () => void;
}>(); } = $props();
let hovering_unselectable = $state(false); let hovering_unselectable = $state(false);
function onclick(e: Event) { async function onclick(e: Event) {
select(selected_display_ids, display.id);
filter_file_selection_for_current_selected_displays();
e.stopPropagation(); e.stopPropagation();
select(selected_display_ids, display.id, 'toggle');
// force file view update
await change_file_path($current_file_path);
} }
function on_preview_click(e: MouseEvent) { async function on_preview_click(e: MouseEvent) {
if ($pinned_display_id === display.id) { if ($pinned_display_id === display.id) {
close_pinned_display(); close_pinned_display();
} else { } else {
$pinned_display_id = display.id; $pinned_display_id = display.id;
} }
update_screenshot(display.id); await screenshot_loop(display.id);
e.stopPropagation(); e.stopPropagation();
} }
</script> </script>
@@ -65,8 +71,8 @@
<div class="size-[50%]"> <div class="size-[50%]">
<Pin class="size-full" /> <Pin class="size-full" />
</div> </div>
{:else if display.preview_url} {:else if display.preview.url}
<img src={display.preview_url} alt="preview" class="w-full object-cover bg-black" /> <img src={display.preview.url} alt="preview" class="w-full object-cover bg-black" />
{:else} {:else}
<!-- No Signal --> <!-- No Signal -->
<div class="size-full bg-black flex justify-center items-center"> <div class="size-full bg-black flex justify-center items-center">
+45 -32
View File
@@ -2,9 +2,10 @@
import { fade, scale } from 'svelte/transition'; import { fade, scale } from 'svelte/transition';
import { import {
all_displays_of_group_selected, all_displays_of_group_selected,
displays,
get_display_by_id, get_display_by_id,
select_all_of_group get_display_groups,
select_all_of_group,
set_new_display_group_order
} from '../ts/stores/displays'; } from '../ts/stores/displays';
import { import {
change_height, change_height,
@@ -27,15 +28,23 @@
import DisplayGroupObject from './DisplayGroupObject.svelte'; import DisplayGroupObject from './DisplayGroupObject.svelte';
import { Pane, Splitpanes } from 'svelte-splitpanes'; import { Pane, Splitpanes } from 'svelte-splitpanes';
import HighlightedText from './HighlightedText.svelte'; import HighlightedText from './HighlightedText.svelte';
import { liveQuery, type Observable } from 'dexie';
let { handle_display_deletion, handle_display_editing } = $props<{ let {
handle_display_deletion,
handle_display_editing
}: {
handle_display_deletion: (display_id: string) => void; handle_display_deletion: (display_id: string) => void;
handle_display_editing: (display_id: string) => void; handle_display_editing: (display_id: string) => void;
}>(); } = $props();
let displays_scroll_box: HTMLElement; let displays_scroll_box: HTMLElement;
let pinned_display: Display | null = $derived( let pinned_display: Observable<Display | null> = liveQuery(() =>
get_display_by_id($pinned_display_id || '', $displays) get_display_by_id($pinned_display_id || '')
);
let display_groups = liveQuery(() => get_display_groups());
let all_groups_selected = liveQuery(() =>
all_selected($display_groups || [], $selected_display_ids)
); );
let last_pinned_pane_size: number = 45; let last_pinned_pane_size: number = 45;
@@ -66,16 +75,22 @@
]; ];
} }
function select_all(current_displays: DisplayGroup[], current_selected_display_ids: string[]) { async function select_all(
const new_value = !all_selected(current_displays, current_selected_display_ids); current_displays: DisplayGroup[],
current_selected_display_ids: string[]
) {
const new_value = !(await all_selected(current_displays, current_selected_display_ids));
for (const display_group of current_displays) { for (const display_group of current_displays) {
select_all_of_group(display_group, new_value); await select_all_of_group(display_group.id, new_value);
} }
} }
function all_selected(current_displays: DisplayGroup[], current_selected_display_ids: string[]) { async function all_selected(
current_displays: DisplayGroup[],
current_selected_display_ids: string[]
) {
for (const display_group of current_displays) { for (const display_group of current_displays) {
if (!all_displays_of_group_selected(display_group, current_selected_display_ids)) { if (!(await all_displays_of_group_selected(display_group.id, current_selected_display_ids))) {
return false; return false;
} }
} }
@@ -121,16 +136,16 @@
> >
<span <span
class="text-xl font-bold pl-2 content-center truncate min-w-0" class="text-xl font-bold pl-2 content-center truncate min-w-0"
title={pinned_display?.name} title={$pinned_display?.name || '...'}
> >
{pinned_display?.name} {$pinned_display?.name || '...'}
</span> </span>
<div class="flex flex-row gap-1"> <div class="flex flex-row gap-1">
<div class="flex flex-row items-center mr-1"> <div class="flex flex-row items-center mr-1">
<span class="text-stone-400"> Aktueller Status: </span> <span class="text-stone-400"> Aktueller Status: </span>
<OnlineState <OnlineState
selected={false} selected={false}
status={pinned_display?.status ?? null} status={$pinned_display?.status ?? null}
className="flex items-center px-2" className="flex items-center px-2"
/> />
</div> </div>
@@ -159,9 +174,9 @@
<div <div
class="h-full bg-stone-800 rounded-b-2xl overflow-hidden flex justify-center items-center" class="h-full bg-stone-800 rounded-b-2xl overflow-hidden flex justify-center items-center"
> >
{#if pinned_display?.preview_url} {#if $pinned_display?.preview.url}
<img <img
src={pinned_display.preview_url} src={$pinned_display.preview.url}
alt="preview" alt="preview"
class="max-h-full max-w-full object-cover bg-black" class="max-h-full max-w-full object-cover bg-black"
/> />
@@ -188,7 +203,7 @@
<div class="flex flex-row gap-1"> <div class="flex flex-row gap-1">
<button <button
class="min-w-40 px-4 rounded-xl cursor-pointer duration-200 transition-colors {get_selectable_color_classes( class="min-w-40 px-4 rounded-xl cursor-pointer duration-200 transition-colors {get_selectable_color_classes(
all_selected($displays, $selected_display_ids), $all_groups_selected || false,
{ {
bg: true, bg: true,
hover: true, hover: true,
@@ -196,13 +211,9 @@
text: true text: true
} }
)}" )}"
onclick={() => select_all($displays, $selected_display_ids)} onclick={async () => await select_all($display_groups || [], $selected_display_ids)}
> >
<span <span>{$all_groups_selected || false ? 'Alle abwählen' : 'Alle auswählen'}</span>
>{all_selected($displays, $selected_display_ids)
? 'Alle abwählen'
: 'Alle auswählen'}</span
>
</button> </button>
<div class="flex flex-row"> <div class="flex flex-row">
<Button <Button
@@ -234,30 +245,32 @@
<div <div
class="min-h-full p-2 flex flex-col gap-4" class="min-h-full p-2 flex flex-col gap-4"
use:dragHandleZone={{ use:dragHandleZone={{
items: $displays, items: $display_groups || [],
type: 'group', type: 'group',
flipDurationMs: dnd_flip_duration_ms, flipDurationMs: dnd_flip_duration_ms,
dropFromOthersDisabled: true, dropFromOthersDisabled: true,
dropTargetStyle: { outline: 'none' } dropTargetStyle: { outline: 'none' }
}} }}
onconsider={(e: CustomEvent) => { onconsider={async (e: CustomEvent) => {
$is_group_drag = true; $is_group_drag = true;
$displays = e.detail.items; await set_new_display_group_order(e.detail.items);
}} }}
onfinalize={(e: CustomEvent) => { onfinalize={async (e: CustomEvent) => {
$displays = e.detail.items; await set_new_display_group_order(e.detail.items);
$is_group_drag = false; $is_group_drag = false;
}} }}
> >
{#if $displays.length === 1 && $displays[0].data.length === 0} {#if ($display_groups || []).length === 0}
<div class="text-stone-500 px-10 py-6 leading-relaxed text-center"> <div class="text-stone-500 px-10 py-6 leading-relaxed text-center">
Es wurden noch keine Bildschirme hinzugefügt. Klicke oben rechts auf 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> <HighlightedText fg="text-stone-450" className="!p-1"
><Settings class="inline pb-1" /></HighlightedText
>
und und
<HighlightedText fg="text-stone-450">Neuen Bildschirm hinzufügen</HighlightedText>. <HighlightedText fg="text-stone-450">Neuen Bildschirm hinzufügen</HighlightedText>.
</div> </div>
{:else} {:else}
{#each $displays as display_group (display_group.id)} {#each $display_groups || [] as display_group (display_group.id)}
<!-- Each Group --> <!-- Each Group -->
<section <section
out:scale={{ duration: dnd_flip_duration_ms, easing: cubicOut }} out:scale={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
@@ -265,7 +278,7 @@
class="outline-none" class="outline-none"
> >
<DisplayGroupObject <DisplayGroupObject
{display_group} display_group_id={display_group.id}
{get_display_menu_options} {get_display_menu_options}
{close_pinned_display} {close_pinned_display}
/> />
+52 -55
View File
@@ -3,6 +3,7 @@
ClipboardPaste, ClipboardPaste,
Download, Download,
FolderPlus, FolderPlus,
Minus,
Info, Info,
Pen, Pen,
RefreshCcw, RefreshCcw,
@@ -17,30 +18,33 @@
import PathBar from './PathBar.svelte'; import PathBar from './PathBar.svelte';
import { selected_display_ids, selected_file_ids } from '../ts/stores/select'; import { selected_display_ids, selected_file_ids } from '../ts/stores/select';
import { import {
all_files,
current_file_path, current_file_path,
get_current_folder_elements, get_current_folder_elements,
get_display_ids_where_file_is_missing,
get_display_ids_where_path_does_not_exist,
get_file_by_id, get_file_by_id,
get_longest_existing_path_and_needed_parts,
run_for_selected_files_on_selected_displays, run_for_selected_files_on_selected_displays,
update_current_folder_on_selected_displays update_current_folder_on_selected_displays,
get_displays_where_path_exists,
create_folder_on_all_selected_displays
} from '../ts/stores/files'; } from '../ts/stores/files';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import FolderElementObject from './FolderElementObject.svelte'; import InodeElement from './InodeElement.svelte';
import PopUp from './PopUp.svelte'; import PopUp from './PopUp.svelte';
import type { PopupContent } from '../ts/types'; import { get_file_primary_key, type Inode, type PopupContent } from '../ts/types';
import TextInput from './TextInput.svelte'; import TextInput from './TextInput.svelte';
import { is_valid_name } from '../ts/utils'; import { is_valid_name } from '../ts/utils';
import { displays, get_display_by_id, run_on_all_selected_displays } from '../ts/stores/displays'; import { delete_files, rename_file } from '../ts/api_handler';
import { create_folders, delete_files, rename_file } from '../ts/api_handler';
import { get } from 'svelte/store';
import HighlightedText from './HighlightedText.svelte'; import HighlightedText from './HighlightedText.svelte';
import { liveQuery } from 'dexie';
let current_name: string = $state(''); let current_name: string = $state('');
let current_valid: boolean = $state(false); let current_valid: boolean = $state(false);
let display_names_where_path_does_not_exist: string[] = $state([]);
let selected_files = liveQuery(() => get_selected_files($selected_display_ids));
let current_folder_elements = liveQuery(() =>
get_current_folder_elements($current_file_path, $selected_display_ids)
);
let popup_content: PopupContent = $state({ let popup_content: PopupContent = $state({
open: false, open: false,
snippet: null, snippet: null,
@@ -48,24 +52,26 @@
closable: true closable: true
}); });
async function get_selected_files(selected_file_ids: string[]): Promise<Inode[]> {
try {
const results = await Promise.all(selected_file_ids.map((id) => get_file_by_id(id)));
return results.filter((element) => element !== null);
} catch (e: unknown) {
console.error('Error on generating selected_files');
return [];
}
}
function popup_close_function() { function popup_close_function() {
popup_content.open = false; popup_content.open = false;
} }
async function create_new_folder() { async function create_new_folder() {
for (const display_id of $selected_display_ids) { await create_folder_on_all_selected_displays(
const display = get_display_by_id(display_id, $displays); current_name.trim(),
if (!display) continue; $current_file_path,
const path_data = get_longest_existing_path_and_needed_parts( $selected_display_ids
$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(); await update_current_folder_on_selected_displays();
popup_close_function(); popup_close_function();
} }
@@ -82,8 +88,8 @@
popup_close_function(); popup_close_function();
} }
const show_edit_file_popup = () => { const show_edit_file_popup = async () => {
const file = get_file_by_id($selected_file_ids[0], $all_files, $current_file_path); const file = await get_file_by_id($selected_file_ids[0]);
if (!file) return; if (!file) return;
const is_folder = file.type === 'inode/directory'; const is_folder = file.type === 'inode/directory';
const extension = is_folder ? '' : '.' + file.name.split('.').at(-1) || ''; const extension = is_folder ? '' : '.' + file.name.split('.').at(-1) || '';
@@ -99,9 +105,12 @@
}; };
}; };
const show_new_folder_popup = () => { const show_new_folder_popup = async () => {
current_name = ''; current_name = '';
current_valid = false; current_valid = false;
display_names_where_path_does_not_exist = (
await get_displays_where_path_exists($current_file_path, $selected_display_ids, true)
).map((display) => display.name);
popup_content = { popup_content = {
open: true, open: true,
snippet: new_folder_popup, snippet: new_folder_popup,
@@ -123,22 +132,18 @@
</script> </script>
{#snippet new_folder_popup()} {#snippet new_folder_popup()}
{#if get_display_ids_where_path_does_not_exist($current_file_path, $selected_display_ids, $all_files).length > 0} {#if display_names_where_path_does_not_exist.length > 0}
<span class="leading-relaxed" <span class="leading-relaxed"
>Der aktuelle Pfad <HighlightedText >Der aktuelle Pfad <HighlightedText
>{$current_file_path.slice(0, $current_file_path.length - 1)}</HighlightedText >{$current_file_path.slice(0, $current_file_path.length - 1)}</HighlightedText
> existiert nicht auf {get_display_ids_where_path_does_not_exist( > existiert nicht auf {display_names_where_path_does_not_exist.length === 1
$current_file_path,
$selected_display_ids,
$all_files
).length === 1
? 'dem Bildschirm' ? 'dem Bildschirm'
: 'den Bildschirmen'} : 'den Bildschirmen'}
{#each get_display_ids_where_path_does_not_exist($current_file_path, $selected_display_ids, $all_files) as display_id, i} {#each display_names_where_path_does_not_exist as display_name, i}
{#if i !== 0} {#if i !== 0}
, ,
{/if} {/if}
<HighlightedText>{get_display_by_id(display_id, $displays)?.name}</HighlightedText> <HighlightedText>{display_name}</HighlightedText>
{/each}. Mit der Erstellung dieses Ordners wird der Pfad automatisch mit leeren Ordnern bis {/each}. Mit der Erstellung dieses Ordners wird der Pfad automatisch mit leeren Ordnern bis
zum aktuellen Pfad aufgefüllt. zum aktuellen Pfad aufgefüllt.
</span> </span>
@@ -148,14 +153,14 @@
bind:current_value={current_name} bind:current_value={current_name}
bind:current_valid bind:current_valid
title="Ordnername" title="Ordnername"
is_valid_function={(input: string) => { is_valid_function={async (input: string) => {
if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen']; if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen'];
const trimmed_input = input.trim(); const trimmed_input = input.trim();
if (trimmed_input.length === 0 || trimmed_input.length > 50) if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge']; return [false, 'Ungültige Länge'];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen']; if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if ( if (
get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).some( (await get_current_folder_elements($current_file_path, $selected_display_ids)).some(
(e) => e.name === trimmed_input (e) => e.name === trimmed_input
) )
) )
@@ -178,30 +183,28 @@
bind:current_value={current_name} bind:current_value={current_name}
bind:current_valid bind:current_valid
title="Neuer {extension === '' ? 'Ordner' : 'Datei'}name" title="Neuer {extension === '' ? 'Ordner' : 'Datei'}name"
is_valid_function={(input: string) => { is_valid_function={async (input: string) => {
if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen']; if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen'];
const trimmed_input = input.trim() + extension; const trimmed_input = input.trim() + extension;
if (trimmed_input.length === 0 || trimmed_input.length > 50) if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge']; return [false, 'Ungültige Länge'];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen']; if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if ( if (
get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).some( (await get_current_folder_elements($current_file_path, $selected_display_ids)).some(
(e) => async (e) => e.name === trimmed_input && get_file_primary_key(e) !== $selected_file_ids[0]
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 [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name']; return [true, 'Gültiger Name'];
}} }}
enter_mode="submit" enter_mode="submit"
enter_function={() => edit_file_name(current_name.trim() + extension)} enter_function={async () => await edit_file_name(current_name.trim() + extension)}
{extension} {extension}
/> />
<div class="flex flex-row justify-end gap-2"> <div class="flex flex-row justify-end gap-2">
<Button <Button
className="px-4 font-bold" className="px-4 font-bold"
click_function={() => edit_file_name(current_name.trim() + extension)} click_function={async () => await edit_file_name(current_name.trim() + extension)}
disabled={!current_valid}>{extension === '' ? 'Ordner' : 'Datei'} umbenennen</Button disabled={!current_valid}>{extension === '' ? 'Ordner' : 'Datei'} umbenennen</Button
> >
</div> </div>
@@ -213,10 +216,8 @@
>{`${$selected_file_ids.length === 1 ? 'Folgendes Objekt' : `Folgende ${$selected_file_ids.length} Objekte`} löschen? (Wiederherstellung nicht möglich)`}</span >{`${$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"> <div class="flex flex-col gap-2 overflow-auto h-full min-h-0 grow-0">
{#each $selected_file_ids {#each $selected_files || [] as file}
.map((file_id) => get_file_by_id(file_id, $all_files, $current_file_path)) <InodeElement {file} not_interactable />
.filter((element) => element !== null) as file}
<FolderElementObject {file} not_interactable />
{/each} {/each}
</div> </div>
</div> </div>
@@ -239,10 +240,6 @@
</div> </div>
{/snippet} {/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-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"> <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"> <span class="text-xl font-bold pl-2 content-center truncate min-w-0">
@@ -312,7 +309,7 @@
> >
<Button <Button
title="Ausgewählte Datei(en) einfügen" title="Ausgewählte Datei(en) einfügen"
className="!p-0 flex relative" className="px-3 flex"
disabled={$selected_display_ids.length === 0} disabled={$selected_display_ids.length === 0}
> >
<ClipboardPaste /> <ClipboardPaste />
@@ -342,12 +339,12 @@
Es sind keine Bildschirme ausgewählt. Es sind keine Bildschirme ausgewählt.
</span> </span>
{:else} {:else}
{#each get_current_folder_elements($all_files, $current_file_path, $selected_display_ids) as folder_element (folder_element.id)} {#each $current_folder_elements || [] as folder_element (get_file_primary_key(folder_element))}
<section in:slide={{ duration: 100 }} class="outline-none"> <section in:slide={{ duration: 100 }} class="outline-none">
<FolderElementObject file={folder_element} /> <InodeElement file={folder_element} />
</section> </section>
{/each} {/each}
{#if get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).length === 0} {#if ($current_folder_elements || []).length === 0}
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center max-w-full"> <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 Es existieren keine Dateien auf {$selected_display_ids.length === 1
? 'dem ausgewähltem Bildchirm' ? 'dem ausgewähltem Bildchirm'
@@ -4,9 +4,16 @@
bg = 'bg-stone-750', bg = 'bg-stone-750',
fg = 'text-stone-200', fg = 'text-stone-200',
className = '' className = ''
} = $props<{ children: any; bg?: string; fg?: string; className?: string }>(); }: {
children: any;
bg?: string;
fg?: string;
className?: string;
} = $props();
</script> </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]"> <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()} {@render children()}
</div> </div>
@@ -1,24 +1,12 @@
<script lang="ts"> <script lang="ts">
import { import { ArrowRight, Ban, FileIcon, Folder, Play } from 'lucide-svelte';
ArrowRight,
Ban,
FileIcon,
Folder,
Play,
RefreshCcwDot,
TriangleAlert
} from 'lucide-svelte';
import { import {
current_height, current_height,
get_selectable_color_classes, get_selectable_color_classes,
get_shifted_color get_shifted_color
} from '../ts/stores/ui_behavior'; } from '../ts/stores/ui_behavior';
import Button from './Button.svelte'; import Button from './Button.svelte';
import { import { supported_file_type_icon, type Inode, get_file_primary_key } from '../ts/types';
supported_file_type_icon,
type FolderElement,
type SupportedFileType
} from '../ts/types';
import { import {
is_selected, is_selected,
@@ -27,39 +15,23 @@
selected_file_ids selected_file_ids
} from '../ts/stores/select'; } from '../ts/stores/select';
import { import {
all_files,
change_file_path, change_file_path,
current_file_path, current_file_path,
get_display_ids_where_file_is_missing get_missing_colliding_display_ids
} from '../ts/stores/files'; } from '../ts/stores/files';
import RefreshPlay from './RefreshPlay.svelte'; import RefreshPlay from './RefreshPlay.svelte';
import { get_file_size_display_string, get_file_type } from '../ts/utils'; import { get_file_size_display_string, get_file_type } from '../ts/utils';
import { open_file } from '../ts/api_handler'; import { open_file } from '../ts/api_handler';
import { import { run_on_all_selected_displays } from '../ts/stores/displays';
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 { 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 { liveQuery } from 'dexie';
let { file, not_interactable = false } = $props<{ let { file, not_interactable = false }: { file: Inode; not_interactable?: boolean } = $props();
file: FolderElement;
not_interactable?: boolean;
}>();
let thumbnail_url: string | null = $state(null); let missing_colliding_displays_ids = liveQuery(() =>
// Update thumbnail_url automatically if data is available get_missing_colliding_display_ids(file, $selected_display_ids)
const subscription = liveQuery(() => db.thumbnail_blobs.get(file.hash)).subscribe({ );
next: async () => { let thumbnail_url = liveQuery(() => get_thumbnail_url(file));
thumbnail_url = await get_thumbnail_url(file.hash);
},
error: (err) => console.error('Dexie subscription error:', err)
});
onDestroy(() => subscription.unsubscribe());
const is_folder = file.type === 'inode/directory'; const is_folder = file.type === 'inode/directory';
@@ -106,13 +78,13 @@
function onclick(e: Event) { function onclick(e: Event) {
if (not_interactable) return; if (not_interactable) return;
select(selected_file_ids, file.id); select(selected_file_ids, get_file_primary_key(file), 'toggle');
e.stopPropagation(); e.stopPropagation();
} }
async function open() { async function open() {
if (is_folder) { if (is_folder) {
change_file_path($current_file_path + file.name + '/'); await change_file_path($current_file_path + file.name + '/');
} else { } else {
const path_to_file = $current_file_path + file.name; const path_to_file = $current_file_path + file.name;
await run_on_all_selected_displays(open_file, true, path_to_file); await run_on_all_selected_displays(open_file, true, path_to_file);
@@ -158,7 +130,7 @@
> >
{#if is_folder} {#if is_folder}
<ArrowRight class="size-full" strokeWidth="3" /> <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} {:else if $missing_colliding_displays_ids && $missing_colliding_displays_ids.missing.length !== 0}
<RefreshPlay className="size-full" /> <RefreshPlay className="size-full" />
{:else if get_file_type(file) !== null} {:else if get_file_type(file) !== null}
<Play class="size-full" strokeWidth="3" /> <Play class="size-full" strokeWidth="3" />
@@ -176,7 +148,7 @@
}} }}
{onclick} {onclick}
class="{get_selectable_color_classes( class="{get_selectable_color_classes(
!not_interactable && is_selected(file.id, $selected_file_ids), !not_interactable && is_selected(get_file_primary_key(file), $selected_file_ids),
{ {
bg: true, bg: true,
hover: !not_interactable, hover: !not_interactable,
@@ -191,9 +163,9 @@
<div class="aspect-square rounded-md flex justify-center items-center"> <div class="aspect-square rounded-md flex justify-center items-center">
{#if is_folder} {#if is_folder}
<Folder class="size-full p-2" /> <Folder class="size-full p-2" />
{:else if thumbnail_url} {:else if $thumbnail_url || null}
<img <img
src={thumbnail_url} src={$thumbnail_url || null}
alt="file_thumbnail" alt="file_thumbnail"
class="object-contain size-full select-none block p-1 rounded-lg" class="object-contain size-full select-none block p-1 rounded-lg"
draggable="false" draggable="false"
@@ -213,7 +185,7 @@
</div> </div>
<div <div
class=" p-1 flex flex-row items-center gap-1 pr-1 {get_grayed_out_text_color_strings( class=" p-1 flex flex-row items-center gap-1 pr-1 {get_grayed_out_text_color_strings(
is_selected(file.id, $selected_file_ids) is_selected(get_file_primary_key(file), $selected_file_ids)
)} duration-200 transition-colors" )} 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}
@@ -251,7 +223,7 @@
</div> </div>
<div <div
class="h-[70%] border {get_grayed_out_border_color_strings( class="h-[70%] border {get_grayed_out_border_color_strings(
is_selected(file.id, $selected_file_ids) is_selected(get_file_primary_key(file), $selected_file_ids)
)} duration-200 transition-colors my-1" )} duration-200 transition-colors my-1"
></div> ></div>
<div <div
@@ -262,7 +234,7 @@
</div> </div>
<div <div
class="h-[70%] border {get_grayed_out_border_color_strings( class="h-[70%] border {get_grayed_out_border_color_strings(
is_selected(file.id, $selected_file_ids) is_selected(get_file_primary_key(file), $selected_file_ids)
)} duration-200 transition-colors" )} duration-200 transition-colors"
></div> ></div>
<div <div
@@ -3,8 +3,7 @@
import { get_selectable_color_classes } from '../ts/stores/ui_behavior'; import { get_selectable_color_classes } from '../ts/stores/ui_behavior';
import key_map_json from './../../../../shared/keys.json'; import key_map_json from './../../../../shared/keys.json';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { selected_display_ids } from '../ts/stores/select'; import { run_on_all_selected_displays } from '../ts/stores/displays';
import { displays, get_display_by_id, run_on_all_selected_displays } from '../ts/stores/displays';
import { send_keyboard_input } from '../ts/api_handler'; import { send_keyboard_input } from '../ts/api_handler';
const bg = 'bg-stone-700'; const bg = 'bg-stone-700';
@@ -64,7 +63,11 @@
{active ? 'Erfassung aktiv' : 'Hier für Erfassung klicken'} {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"> <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)} {#each last_keys as key (key.id)}
<span animate:flip={{ duration: 200 }} in:fade={{ duration: 200 }} out:fade={{ duration: 500 }} >{key.key}</span> <span
animate:flip={{ duration: 200 }}
in:fade={{ duration: 200 }}
out:fade={{ duration: 500 }}>{key.key}</span
>
{/each} {/each}
</div> </div>
</div> </div>
@@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
import { type DisplayStatus } from "../ts/types"; import { type DisplayStatus } from '../ts/types';
import { display_status_to_info } from "../ts/utils"; import { display_status_to_info } from '../ts/utils';
let { selected, status, className = "" } = $props<{ let {
selected,
status,
className = ''
}: {
selected: boolean; selected: boolean;
status: DisplayStatus; status: DisplayStatus;
className?: string; className?: string;
}>(); } = $props();
function get_text_color(selected: boolean, status: DisplayStatus) { function get_text_color(selected: boolean, status: DisplayStatus) {
switch (status) { switch (status) {
+16 -11
View File
@@ -8,9 +8,11 @@
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
let { bg = 'bg-stone-700' } = $props<{ let {
bg = 'bg-stone-700'
}: {
bg?: string; bg?: string;
}>(); } = $props();
let outside_container: HTMLDivElement; let outside_container: HTMLDivElement;
let inside_container: HTMLDivElement; let inside_container: HTMLDivElement;
@@ -79,21 +81,21 @@
out.push({ out.push({
name: '  '.repeat(i) + hidden_folders[i], name: '  '.repeat(i) + hidden_folders[i],
class: 'truncate max-w-80', class: 'truncate max-w-80',
on_select: () => { on_select: async () => {
open_path(i + 1, path); await open_path(i + 1, path);
} }
}); });
} }
return out; return out;
} }
function open_path(index_of_all_folders: number, path: string) { async function open_path(index_of_all_folders: number, path: string) {
let new_path = '/'; let new_path = '/';
const all_folders = get_folders(path); const all_folders = get_folders(path);
for (let i = 0; i < index_of_all_folders; i++) { for (let i = 0; i < index_of_all_folders; i++) {
new_path += all_folders[i] + '/'; new_path += all_folders[i] + '/';
} }
change_file_path(new_path); await change_file_path(new_path);
} }
onMount(() => { onMount(() => {
@@ -113,11 +115,14 @@
<Button <Button
className="py-1 shrink-0 grow-0 w-10" className="py-1 shrink-0 grow-0 w-10"
{bg} {bg}
click_function={(e) => { click_function={async () => {
open_path(0, $current_file_path); await open_path(0, $current_file_path);
}} }}
> >
<House class="size-full transition-all duration-100" strokeWidth={$current_file_path === '/' ? 2.7 : 2}/> <House
class="size-full transition-all duration-100"
strokeWidth={$current_file_path === '/' ? 2.7 : 2}
/>
</Button> </Button>
</div> </div>
{#if cut_folders !== 0} {#if cut_folders !== 0}
@@ -144,8 +149,8 @@
? 'max-w-80 font-bold' ? 'max-w-80 font-bold'
: 'max-w-30'}" : 'max-w-30'}"
{bg} {bg}
click_function={(e) => { click_function={async () => {
open_path(cut_folders + i + 1, $current_file_path); await open_path(cut_folders + i + 1, $current_file_path);
}} }}
> >
<ChevronRight class="shrink-0 text-stone-500 h-full" /> <ChevronRight class="shrink-0 text-stone-500 h-full" />
+2 -2
View File
@@ -10,12 +10,12 @@
close_function, close_function,
className = '', className = '',
snippet_container_class = '' snippet_container_class = ''
} = $props<{ }: {
content: PopupContent; content: PopupContent;
close_function: () => void; close_function: () => void;
className?: string; className?: string;
snippet_container_class?: string; snippet_container_class?: string;
}>(); } = $props();
function try_to_close() { function try_to_close() {
if (!content.closable || !content.open) return; if (!content.closable || !content.open) return;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
let {className = ''} = $props<{className?: string}>(); let { className = '' }: { className?: string } = $props();
</script> </script>
<svg <svg
@@ -16,28 +16,28 @@
extension = null, extension = null,
enter_mode = 'none', enter_mode = 'none',
enter_function = null enter_function = null
} = $props<{ }: {
current_value: string; current_value: string;
current_valid: boolean; current_valid: boolean;
className?: string; className?: string;
bg?: string; bg?: string;
title: string; title: string;
placeholder?: string; placeholder?: string;
is_valid_function?: ((input: string) => [boolean, string]) | null; is_valid_function?: ((input: string) => [boolean, string] | Promise<[boolean, string]>) | null;
focused_on_start?: boolean; focused_on_start?: boolean;
extension?: string | null; extension?: string | null;
enter_mode?: 'none' | 'focus_next' | 'submit'; enter_mode?: 'none' | 'focus_next' | 'submit';
enter_function?: (() => void) | null; enter_function?: (() => void) | null;
}>(); } = $props();
let focus_bg = get_shifted_color(bg, 100); let focus_bg = get_shifted_color(bg, 100);
let focused: boolean = $state(false); let focused: boolean = $state(false);
let current_info = $state(''); let current_info = $state('');
let input_element: HTMLInputElement; let input_element: HTMLInputElement;
function validate_input() { async function validate_input() {
if (!is_valid_function) return; if (!is_valid_function) return;
[current_valid, current_info] = is_valid_function(current_value.trim()); [current_valid, current_info] = await is_valid_function(current_value.trim());
} }
function get_highlighting_string(): string { function get_highlighting_string(): string {
@@ -72,12 +72,12 @@
} }
} }
onMount(() => { onMount(async () => {
validate_input(); await validate_input();
if (focused_on_start && input_element) input_element.focus(); if (focused_on_start && input_element) input_element.focus();
selected_display_ids.subscribe(() => { selected_display_ids.subscribe(async () => {
validate_input(); await validate_input();
}); });
}); });
</script> </script>
@@ -114,11 +114,11 @@
}; };
} }
function show_text() { async function show_text() {
const html = const html =
editor_state.editor?.getHTML() + editor_state.editor?.getHTML() +
`<style>:root {--background-color: ${color_states.bg.value} !important;} `<style>:root {--background-color: ${color_states.bg.value} !important;}
</style>`; </style>`;
await run_on_all_selected_displays(show_html, true, html); await run_on_all_selected_displays(show_html, true, html);
} }
+1
View File
@@ -1 +1,2 @@
export const prerender = true; export const prerender = true;
export const ssr = false;
+18 -20
View File
@@ -10,7 +10,6 @@
import TextInput from '../components/TextInput.svelte'; import TextInput from '../components/TextInput.svelte';
import { import {
add_display, add_display,
displays,
edit_display_data, edit_display_data,
get_display_by_id, get_display_by_id,
is_display_name_taken, is_display_name_taken,
@@ -35,6 +34,7 @@
title_class: '!text-xl', title_class: '!text-xl',
closable: true closable: true
}); });
let remove_display_name = $state('');
const text_inputs_valid_null_values = { const text_inputs_valid_null_values = {
name: { valid: false, value: '' }, name: { valid: false, value: '' },
@@ -81,7 +81,8 @@
}; };
}; };
const show_remove_display_popup = (display_id: string) => { const show_remove_display_popup = async (display_id: string) => {
remove_display_name = (await get_display_by_id(display_id))?.name || '?';
popup_content = { popup_content = {
open: true, open: true,
snippet: remove_display_popup, snippet: remove_display_popup,
@@ -93,8 +94,8 @@
}; };
}; };
const show_edit_display_popup = (display_id: string) => { const show_edit_display_popup = async (display_id: string) => {
const display = get_display_by_id(display_id, $displays); const display = await get_display_by_id(display_id);
if (!display) return; if (!display) return;
// insert existing values in text_inputs_valid // insert existing values in text_inputs_valid
for (const key of Object.keys(text_inputs_valid) as (keyof typeof text_inputs_valid)[]) { for (const key of Object.keys(text_inputs_valid) as (keyof typeof text_inputs_valid)[]) {
@@ -112,17 +113,14 @@
}; };
}; };
onMount(() => { onMount(on_start);
on_start();
});
</script> </script>
{#snippet remove_display_popup(display_id: string)} {#snippet remove_display_popup(display_id: string)}
<div class="max-w-prose px-2"> <div class="max-w-prose px-2">
Soll der Bildschirm <HighlightedText Soll der Bildschirm <HighlightedText>{remove_display_name}</HighlightedText> wirklich gelöscht werden?
>{get_display_by_id(display_id, $displays)?.name || '?'}</HighlightedText Dadurch wird es von diesem Controller nicht mehr erreichbar. Die Installation auf dem Gerät bleibt
> wirklich gelöscht werden? Dadurch wird es von diesem Controller nicht mehr erreichbar. Die Installation bestehen. Mit dem erneuten Hinzufügen des Bildschirms wird er wieder steuerbar.
auf dem Gerät bleibt bestehen. Mit dem erneuten Hinzufügen des Bildschirms wird er wieder steuerbar.
</div> </div>
<div class="flex flex-row justify-end gap-2"> <div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={popup_close_function}>Abbrechen</Button> <Button className="px-4 font-bold" click_function={popup_close_function}>Abbrechen</Button>
@@ -130,8 +128,8 @@
hover_bg="bg-red-400" hover_bg="bg-red-400"
active_bg="bg-red-500" active_bg="bg-red-500"
className="px-4 flex text-red-400 hover:text-stone-100" className="px-4 flex text-red-400 hover:text-stone-100"
click_function={() => { click_function={async () => {
remove_display(display_id); await remove_display(display_id);
popup_close_function(); popup_close_function();
}}>Löschen</Button }}>Löschen</Button
> >
@@ -145,13 +143,13 @@
bind:current_valid={text_inputs_valid.name.valid} bind:current_valid={text_inputs_valid.name.valid}
title="Anzeigename" title="Anzeigename"
placeholder="z.B. Beamer vorne links" placeholder="z.B. Beamer vorne links"
is_valid_function={(input: string) => { is_valid_function={async (input: string) => {
if (!!existing_display_id) { if (!!existing_display_id) {
if (input === get_display_by_id(existing_display_id, $displays)?.name) if (input === (await get_display_by_id(existing_display_id))?.name)
return [true, 'Gültiger Name']; return [true, 'Gültiger Name'];
} }
if (input.length === 0 || input.length > 50) return [false, 'Ungültige Länge']; if (input.length === 0 || input.length > 50) return [false, 'Ungültige Länge'];
if (is_display_name_taken(input)) return [false, 'Name bereits verwendet']; if (await is_display_name_taken(input)) return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name']; return [true, 'Gültiger Name'];
}} }}
enter_mode="focus_next" enter_mode="focus_next"
@@ -199,8 +197,8 @@
: [false, 'Ungültige MAC-Adresse']; : [false, 'Ungültige MAC-Adresse'];
}} }}
enter_mode="submit" enter_mode="submit"
enter_function={() => { enter_function={async () => {
finalize_add_edit_display(existing_display_id); await finalize_add_edit_display(existing_display_id);
}} }}
/> />
<div class="flex flex-row gap-2 justify-end pt-2"> <div class="flex flex-row gap-2 justify-end pt-2">
@@ -212,8 +210,8 @@
disabled={!all_text_inputs_valid()} disabled={!all_text_inputs_valid()}
className="{!!existing_display_id ? 'px-4' : 'pl-3 pr-4 gap-2'} font-bold" className="{!!existing_display_id ? 'px-4' : 'pl-3 pr-4 gap-2'} font-bold"
bg="bg-stone-650" bg="bg-stone-650"
click_function={() => { click_function={async () => {
finalize_add_edit_display(existing_display_id); await finalize_add_edit_display(existing_display_id);
}} }}
>{#if !!existing_display_id} >{#if !!existing_display_id}
Speichern Speichern
+243 -186
View File
@@ -1,49 +1,58 @@
import { notifications } from "./stores/notification"; import { notifications } from './stores/notification';
import { to_display_status, type DisplayStatus, type FolderElement, type RequestResponse, type ShellCommandResponse, type TreeElement } from "./types"; import {
import { get_uuid } from "./utils"; to_display_status,
type DisplayStatus,
type Inode,
type RequestResponse,
type ShellCommandResponse,
type TreeElement
} from './types';
export async function get_screenshot(ip: string): Promise<Blob | null> { export async function get_screenshot(ip: string): Promise<Blob | null> {
const options = { method: 'PATCH' }; const options = { method: 'PATCH' };
const response = await request_display(ip, '/takeScreenshot', options); const response = await request_display(ip, '/takeScreenshot', options);
if (!response.ok || !response.blob) return null; if (!response.ok || !response.blob) return null;
return response.blob; return response.blob;
} }
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<void> {
const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } }; const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } };
await request_display(ip, `/file${path_to_file}`, options); await request_display(ip, `/file${path_to_file}`, options);
} }
export async function send_keyboard_input(ip: string, key: string): Promise<void> { export async function send_keyboard_input(ip: string, key: string): Promise<void> {
const options = { const options = {
method: 'PATCH', method: 'PATCH',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
key: key, key: key
}), })
}; };
await request_display(ip, '/keyboardInput', options); await request_display(ip, '/keyboardInput', options);
} }
export async function show_html(ip: string, html: string) { export async function show_html(ip: string, html: string) {
const options = { const options = {
method: 'PATCH', method: 'PATCH',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
html: html html: html
}) })
}; };
await request_display(ip, '/showHTML', options); 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(
interface FileInfo { ip: string,
name: string; path: string
type: string; ): Promise<{ folder_element: Inode; date_created: Date }[] | null> {
size: string; interface FileInfo {
created: string; name: string;
} type: string;
const command = `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do 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") typ=$(file -b --mime-type -- "$f")
size=$(stat -c '%s' -- "$f") size=$(stat -c '%s' -- "$f")
created=$(stat -c '%w' -- "$f") created=$(stat -c '%w' -- "$f")
@@ -52,194 +61,242 @@ export async function get_file_data(ip: string, path: string): Promise<FolderEle
'{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n' '{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n'
echo echo
done done
` `;
const raw_response = await run_shell_command(ip, command, true); const raw_response = await run_shell_command(ip, command, true);
if (!raw_response.ok || !raw_response.json) return null; if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse; const json_response = raw_response.json as ShellCommandResponse;
if (json_response.exitCode === 0 && json_response.stdout.trim() === '') return []; if (json_response.exitCode === 0 && json_response.stdout.trim() === '') return [];
if (handle_shell_error(ip, json_response, command, true)) return null; if (handle_shell_error(ip, json_response, command, true)) return null;
const response: FileInfo[] = json_response.stdout.trim() const response: FileInfo[] = json_response.stdout
.split("\n") .trim()
.filter(Boolean) .split('\n')
.map((line: string) => JSON.parse(line) as FileInfo); .filter(Boolean)
.map((line: string) => JSON.parse(line) as FileInfo);
const folder_element_list: FolderElement[] = []; const folder_element_list: { folder_element: Inode; date_created: Date }[] = [];
for (const response_element of response) { for (const response_element of response) {
// filter hidden files (start with '.' -> './.config') // filter hidden files (start with '.' -> './.config')
if (response_element.name.charAt(2) !== '.') { if (response_element.name.charAt(2) === '.') continue;
const truncated = { const folder_element: Inode = {
...response_element, path: path,
created: response_element.created.slice(0, 16) // truncated to YYYY-MM-DD hh-mm -> no (milli)seconds name: response_element.name.slice(2), // remove "./"
}; type: response_element.type,
size: Number(response_element.size),
const folder_element: FolderElement = { thumbnail: null
id: get_uuid(), };
hash: JSON.stringify(truncated), folder_element_list.push({ folder_element, date_created: new Date(response_element.created) });
name: response_element.name.slice(2), // remove "./" }
type: response_element.type, return folder_element_list;
date_created: new Date(response_element.created),
size: Number(response_element.size),
};
folder_element_list.push(folder_element);
}
}
return folder_element_list;
} }
export async function get_file_tree_data(ip: string, path: string): Promise<TreeElement[] | null> { export async function get_file_tree_data(ip: string, path: string): Promise<TreeElement[] | null> {
const command = `cd ".${path}" && tree -Js` const command = `cd ".${path}" && tree -Js`;
const raw_response = await run_shell_command(ip, command, true); const raw_response = await run_shell_command(ip, command, true);
if (!raw_response.ok || !raw_response.json) return null; if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse; const json_response = raw_response.json as ShellCommandResponse;
if (handle_shell_error(ip, json_response, command, true)) return null; if (handle_shell_error(ip, json_response, command, true)) return null;
return (JSON.parse(json_response.stdout.trim()) as [TreeElement, any])[0].contents || null; const tree_element: TreeElement | null = JSON.parse(json_response.stdout.trim())[0] || null;
return tree_element?.contents || null;
} }
export async function create_folders(ip: string, path: string, folder_names: string[]): Promise<void> { export async function create_folders(
let command = `cd ".${path}"`; ip: string,
path: string,
folder_names: string[]
): Promise<void> {
let command = `cd ".${path}"`;
for (const part of folder_names) { for (const part of folder_names) {
command += ` && mkdir "${part}" && cd "${part}/"`; command += ` && mkdir "${part}" && cd "${part}/"`;
} }
const raw_response = await run_shell_command(ip, command); const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return; if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse; const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true); 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> { export async function rename_file(
const command: string = `cd ".${path}" && mv "${old_file_name}" "${new_file_name}"`; 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); const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return; if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse; const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true); handle_shell_error(ip, json_response, command, true);
} }
export async function delete_files(ip: string, current_path: string, file_names: string[]): Promise<void> { export async function delete_files(
let command: string = `cd ".${current_path}"`; ip: string,
for (const file_name of file_names) { current_path: string,
command += ` && rm -r "${file_name}"`; file_names: string[]
} ): Promise<void> {
const raw_response = await run_shell_command(ip, command); let command: string = `cd ".${current_path}"`;
if (!raw_response.ok || !raw_response.json) return; for (const file_name of file_names) {
const json_response = raw_response.json as ShellCommandResponse; command += ` && rm -r "${file_name}"`;
handle_shell_error(ip, json_response, command, true); }
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> { export async function show_blackscreen(ip: string): Promise<void> {
const options = { const options = {
method: 'PATCH', method: 'PATCH',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
html: `` html: ``
}) })
}; };
await request_display(ip, '/showHTML', options); await request_display(ip, '/showHTML', options);
} }
export async function get_thumbnail_blob(ip: string, path_to_file: string): Promise<Blob | null> { 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]); const raw_response = await request_display(
if (!raw_response.ok || !raw_response.blob) return null ip,
return raw_response.blob; `/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> { export async function ping_ip(ip: string): Promise<DisplayStatus> {
const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' }); const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' });
if (!raw_response.ok || !raw_response.json) return null; if (!raw_response.ok || !raw_response.json) return null;
return raw_response.json.status ? to_display_status(raw_response.json.status) : null;
const status = raw_response.json.status;
if (typeof status === 'string') {
return to_display_status(status);
}
return null;
} }
async function request_display(
ip: string,
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> { api_route: string,
const url = `http://${ip}:1323/api${api_route}`; options: { method: string; headers?: Record<string, string>; body?: string },
return await request(url, options, log_in_debug, supress_error_handling_http_codes); log_in_debug: boolean = false,
supress_error_handling_http_codes: number[] = []
): Promise<RequestResponse> {
const url = `http://${ip}:1323/api${api_route}`;
return await request(url, options, log_in_debug, supress_error_handling_http_codes);
} }
async function request_control(api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<RequestResponse> { async function request_control(
const url = `${window.location.origin}/api${api_route}`; api_route: string,
return await request(url, options); options: { method: string; headers?: Record<string, string>; body?: string }
): Promise<RequestResponse> {
const url = `${window.location.origin}/api${api_route}`;
return await request(url, options);
} }
async function request(
url: string,
options: { method: string; headers?: Record<string, string>; body?: string },
log_in_debug: boolean = false,
supress_error_handling_http_codes: number[] = []
): Promise<RequestResponse> {
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);
}
const response = await fetch(url + cache_buster, options);
if (response.ok || supress_error_handling_http_codes.includes(response.status)) {
const contentType = response.headers.get('content-type') || '';
let request_response: RequestResponse;
if (!contentType.includes('application/json')) {
const blob: Blob = await response.blob();
request_response = { ok: response.ok, http_code: response.status, blob: blob };
} else {
const json: Record<string, unknown> = await response.json();
request_response = { ok: response.ok, http_code: response.status, json: json };
}
if (log_in_debug) {
console.debug(request_response);
} else {
console.log(request_response);
}
return request_response;
}
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> { let error_description: string;
try { try {
const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`; const json: { description: string } = await response.json();
if (log_in_debug) { error_description = json.description;
console.debug(url + cache_buster, options.body); } catch (error_on_json_parsing: unknown) {
} else { error_description = `unknown error: ${error_on_json_parsing}`;
console.log(url + cache_buster, options.body); }
} console.error(url, error_description);
const response = await fetch(url + cache_buster, options); notifications.push(
if (response.ok || supress_error_handling_http_codes.includes(response.status)) { 'error',
const contentType = response.headers.get("content-type") || ""; `Fehler ${response.status} bei API-Anfrage`,
let request_response: RequestResponse; `${url}\nFehler: ${error_description}`
if (!contentType.includes("application/json")) { );
const blob: Blob = await response.blob(); } catch (error: unknown) {
request_response = { ok: response.ok, http_code: response.status, blob: blob }; if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) {
} else { console.log('Request failed - Is the targeted device online?');
const json: Object = await response.json(); } else {
request_response = { ok: response.ok, http_code: response.status, json: json }; console.error(url, error);
} notifications.push('error', `Fataler Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
if (log_in_debug) { }
console.debug(request_response); }
} else { return { ok: false };
console.log(request_response);
}
return request_response;
}
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}`;
}
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}`);
}
}
return { ok: false };
} }
function handle_shell_error(
ip: string,
shell_response: ShellCommandResponse,
function handle_shell_error(ip: string, shell_response: ShellCommandResponse, shell_command: string, command_includs_cd: boolean): boolean { shell_command: string,
if (shell_response.exitCode !== 0) { command_includs_cd: boolean
if (command_includs_cd && shell_response.stderr && /bash: line \d+: cd: .+: No such file or directory/.test(shell_response.stderr)) { ): boolean {
console.log("current file_path does not exist on display:", ip); if (shell_response.exitCode !== 0) {
return true; if (
} command_includs_cd &&
console.error(shell_response); shell_response.stderr &&
notifications.push("error", `Fehler ${shell_response.exitCode} in API-Shell`, `${ip}\n${shell_command}\nFehler: ${shell_response.stderr}`); /bash: line \d+: cd: .+: No such file or directory/.test(shell_response.stderr)
return true; ) {
} console.log('current file_path does not exist on display:', ip);
if (shell_response.stdout.trim() === '') return true; return true;
return false; }
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> { async function run_shell_command(
const options = { ip: string,
method: 'PATCH', command: string,
headers: { 'content-type': 'application/json' }, log_in_debug: boolean = false
body: JSON.stringify({ ): Promise<RequestResponse> {
command: command const options = {
}) method: 'PATCH',
}; headers: { 'content-type': 'application/json' },
return await request_display(ip, '/shellCommand', options, log_in_debug); body: JSON.stringify({
command: command
})
};
return await request_display(ip, '/shellCommand', options, log_in_debug);
} }
+56
View File
@@ -0,0 +1,56 @@
import Dexie, { type Table } from 'dexie';
import type { Display, DisplayGroup, Inode } from './types';
export interface FileOnDisplay {
display_id: string;
file_primary_key: string; // JSON.stringify([string, string, number, string])
date_created: Date;
is_loading: boolean;
percentage: number;
}
export class FileDatabase extends Dexie {
files!: Table<Inode, [string, string, number, string]>;
files_on_display!: Table<FileOnDisplay, [string, string]>;
displays!: Table<Display, string>;
display_groups!: Table<DisplayGroup, string>;
constructor() {
super('FileDatabase');
this.version(1).stores({
files: `
[path+name+size+type],
path,
name,
size,
type,
thumbnail
`,
files_on_display: `
[display_id+file_primary_key],
display_id,
file_primary_key,
date_created,
is_loading,
percentage
`,
displays: `
id,
ip,
mac,
position,
preview,
group_id,
name,
status
`,
display_groups: `
id,
position
`
});
}
}
export const db = new FileDatabase();
@@ -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();
Regular → Executable
+23 -23
View File
@@ -1,34 +1,34 @@
import { get } from "svelte/store"; import { screenshot_loop } from './stores/displays';
import { displays, run_on_all_selected_displays, update_displays_with_map, update_screenshot } from "./stores/displays" import { ping_ip } from './api_handler';
import { ping_ip } from "./api_handler"; import type { Display } from './types';
import type { Display } from "./types"; import { update_folder_elements_recursively } from './stores/files';
import { change_file_path, update_folder_elements_recursively } from "./stores/files"; import { db } from './files_display.db';
import { db } from "./indexdb/file_thumbnails.db";
const update_display_status_interval_seconds = 20; const update_display_status_interval_seconds = 20;
export async function on_start() { export async function on_start() {
await db.thumbnail_blobs.clear(); await db.files.clear();
await update_all_display_status(); await db.files_on_display.clear();
await setInterval(update_all_display_status, update_display_status_interval_seconds * 1000); await update_all_display_status();
await setInterval(update_all_display_status, update_display_status_interval_seconds * 1000);
} }
async function update_all_display_status() { async function update_all_display_status() {
await update_displays_with_map(async (display: Display) => { const all_displays: Display[] = await db.displays.toArray();
const new_status = await ping_ip(display.ip); for (const display of all_displays) {
if (new_status === null && display.status !== null) return display; const new_status = await ping_ip(display.ip);
if (new_status === "app_online" && display.status !== "app_online") { if (new_status === null && display.status !== null) continue;
await on_display_start(display); if (new_status === 'app_online' && display.status !== 'app_online') {
} on_display_start(display);
return { ...display, status: new_status, }; }
}); if (new_status !== display.status) {
console.log("Display Status updated") display.status = new_status;
await db.displays.put(display); // save
}
}
} }
async function on_display_start(display: Display) { async function on_display_start(display: Display) {
await update_folder_elements_recursively(display, '/'); await update_folder_elements_recursively(display, '/');
await update_screenshot(display.id); await screenshot_loop(display.id);
} }
+167 -162
View File
@@ -1,191 +1,196 @@
import { get, writable, type Writable } from "svelte/store"; import { get } from 'svelte/store';
import type { Display, DisplayGroup, DisplayStatus } from "../types"; import type { Display, DisplayGroup, DisplayStatus } from '../types';
import { is_selected, select, selected_display_ids } from "./select"; import { is_selected, select, selected_display_ids } from './select';
import { get_uuid, image_content_hash } from "../utils"; import { get_uuid, image_content_hash } from '../utils';
import { get_screenshot } from "../api_handler"; import { get_screenshot } from '../api_handler';
import { filter_file_selection_for_current_selected_displays } from "./files"; import { delete_and_deselect_unique_files_from_display } from './files';
import { db } from '../files_display.db';
export const displays: Writable<DisplayGroup[]> = writable<DisplayGroup[]>([{ export async function is_display_name_taken(name: string): Promise<boolean> {
id: get_uuid(), const exists = await db.displays.where('name').equals(name).first();
data: [] return !!exists;
}]);
export function is_display_name_taken(name: string): boolean {
const display_groups = get(displays);
return display_groups.some(group =>
group.data.some(display => display.name.trim().toLowerCase() === name.trim().toLowerCase())
);
} }
export function add_display(ip: string, mac: string | null, name: string, status: DisplayStatus) { export async function add_display(
displays.update((displays: DisplayGroup[]) => { ip: string,
displays[0].data.push({ id: get_uuid(), ip, preview_url: null, preview_timeout_id: null, mac, name, status }); mac: string | null,
return displays; name: string,
}); status: DisplayStatus
) {
if (await is_display_name_taken(name)) return;
const new_id = get_uuid();
const group = await db.display_groups.toCollection().first();
let group_id: string;
if (group) {
group_id = group.id;
console.log('DISPLAYGROUP WURDE NICHT ERSTELLT');
} else {
group_id = get_uuid();
await db.display_groups.put({ id: group_id, position: 0 });
console.log('DISPLAYGROUP WURDE ERSTELLT');
}
const element_count_in_group = (await db.displays.where('group_id').equals(group_id).toArray())
.length;
await db.displays.put({
id: new_id,
ip,
mac,
position: element_count_in_group,
preview: { currently_updating: false, url: null },
group_id: group_id,
name,
status
});
} }
export async function edit_display_data(display_id: string, ip: string, mac: string | null, name: string) { export async function edit_display_data(
await update_displays_with_map((display: Display) => { display_id: string,
if (display.id !== display_id) return display; ip: string,
return { ...display, ip: ip, mac: mac, name: name }; mac: string | null,
}) name: string
) {
let display = await db.displays.get(display_id);
if (!display) return;
display = { ...display, ip: ip, mac: mac, name: name };
await db.displays.put(display); // save
} }
export function remove_display(display_id: string) { export async function remove_display(display_id: string) {
select(selected_display_ids, display_id, false); select(selected_display_ids, display_id, 'deselect');
filter_file_selection_for_current_selected_displays(); await delete_and_deselect_unique_files_from_display(display_id);
displays.update((displays: DisplayGroup[]) => {
displays = displays.map(display_group => ({
...display_group,
data: display_group.data.filter(display => display.id !== display_id)
}));
return displays;
});
// TODO remove ID from Files usw. const group_id = (await db.displays.get(display_id))?.group_id;
await db.displays.delete(display_id);
if (group_id && (await db.displays.where('group_id').equals(group_id).toArray()).length === 0) {
await db.display_groups.delete(group_id); // delete empty group
}
} }
export function all_displays_of_group_selected(display_group: DisplayGroup, current_selected_displays: string[]) { export async function all_displays_of_group_selected(
if (display_group.data.length === 0) return false; display_group_id: string,
for (const display of display_group.data) { current_selected_displays: string[]
if (!is_selected(display.id, current_selected_displays)) { ): Promise<boolean> {
return false; const displays_of_group: Display[] = await db.displays
} .where('group_id')
} .equals(display_group_id)
return true; .toArray();
if (displays_of_group.length === 0) return false;
for (const display of displays_of_group) {
if (!is_selected(display.id, current_selected_displays)) {
return false;
}
}
return true;
} }
export function select_all_of_group(display_group: DisplayGroup, new_value: boolean | null = null) { export async function select_all_of_group(
for (const display of display_group.data) { display_group_id: string,
select(selected_display_ids, display.id, new_value); new_value: boolean | null = null
} ) {
filter_file_selection_for_current_selected_displays(); const displays_of_group: Display[] = await db.displays
.where('group_id')
.equals(display_group_id)
.toArray();
for (const display of displays_of_group) {
let action: string;
if (new_value === true) {
action = 'select';
} else {
action = 'deselect';
}
select(selected_display_ids, display.id, action as 'toggle' | 'select' | 'deselect');
}
} }
export function set_new_display_group_data(display_group_id: string, new_data: Display[]) { export async function get_display_by_id(display_id: string): Promise<Display | null> {
displays.update((displays: DisplayGroup[]) => { return (await db.displays.get(display_id)) ?? null;
for (const display_group of displays) {
if (display_group.id === display_group_id) {
display_group.data = new_data;
}
}
return displays;
});
} }
export function get_display_by_id(display_id: string, display_group_array: DisplayGroup[]) { export async function screenshot_loop(display_id: string, initial_retry_count: number = 5) {
const displays_array = display_group_array; const display = await db.displays.get(display_id);
for (const display_group of displays_array) { if (!display || display.preview.currently_updating) return;
for (const display of display_group.data) {
if (display.id === display_id) { display.preview.currently_updating = true;
return display; await db.displays.update(display.id, { preview: display.preview });
}
} let last_hash: number | null = null;
}
return null; let retry_count = initial_retry_count;
while (retry_count > 0) {
retry_count -= 1;
const new_blob = await get_screenshot(display.ip);
if (!new_blob) {
display.preview = { currently_updating: false, url: null };
await db.displays.update(display.id, { preview: display.preview });
return;
}
const new_hash = await image_content_hash(new_blob);
if (last_hash !== new_hash) {
if (display.preview.url) {
URL.revokeObjectURL(display.preview.url);
}
last_hash = new_hash;
display.preview.url = URL.createObjectURL(new_blob);
await db.displays.update(display.id, { preview: display.preview });
retry_count = initial_retry_count;
}
await new Promise((resolve) => setTimeout(resolve, 2000)); // sleep 2s
}
display.preview.currently_updating = false;
await db.displays.update(display.id, { preview: display.preview });
} }
export async function run_on_all_selected_displays<T extends unknown[]>(
export function add_empty_display_group() { run_function: (ip: string, ...args: T) => void | Promise<void>,
displays.update((displays: DisplayGroup[]) => { update_screenshot_afterwards: boolean,
displays.push({ ...args: T
id: get_uuid(), ) {
data: [], for (const display_id of get(selected_display_ids)) {
}); const display_ip = (await get_display_by_id(display_id))?.ip;
return displays; if (display_ip) {
}); await run_function(display_ip, ...args);
if (update_screenshot_afterwards) {
await screenshot_loop(display_id);
}
}
}
} }
export async function get_display_groups(): Promise<DisplayGroup[]> {
export function remove_empty_display_groups() { return await db.display_groups.orderBy('position').toArray();
displays.update((displays: DisplayGroup[]) => {
for (let i = displays.length - 1; i >= 0; i--) {
if (displays[i].data.length === 0) {
displays.splice(i, 1);
}
}
return displays;
});
} }
export async function update_screenshot(display_id: string, check_type: "first_check" | "last_check_different" | "last_check_same" = "first_check") { export async function get_display_ids_in_group(display_group_id: string): Promise<Display[]> {
const display_ip = get_display_by_id(display_id, get(displays))?.ip; const displays: Display[] = await db.displays
if (!display_ip) return; .where('group_id')
const new_blob = await get_screenshot(display_ip); .equals(display_group_id)
if (!new_blob) { .sortBy('position');
update_displays_with_map((display: Display) => { return displays;
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";
if (check_type !== "first_check") {
if (display && display.preview_url) {
const old_blob = await fetch(display.preview_url).then(r => r.blob());
const new_hash = await image_content_hash(new_blob);
const old_hash = await image_content_hash(old_blob);
update_needed = old_hash !== new_hash; // if different -> update
}
}
let new_preview_timeout_id: number | null = null;
if (update_needed || check_type === "last_check_different") {
new_preview_timeout_id = setTimeout(async () => { await update_screenshot(display_id, update_needed ? "last_check_different" : "last_check_same") }, 2 * 1000);
}
if (display?.preview_timeout_id) {
clearInterval(display.preview_timeout_id);
}
if (update_needed) {
update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display;
if (display.preview_url) {
URL.revokeObjectURL(display.preview_url);
}
const new_url = URL.createObjectURL(new_blob);
return { ...display, preview_url: new_url, preview_timeout_id: new_preview_timeout_id };
})
}
} }
export async function set_new_display_order(new_ordered_items: Display[]) {
export async function update_displays_with_map(update_function: (display: Display) => Display | Promise<Display>) { for (let i = 0; i < new_ordered_items.length; i++) {
const display_groups = get(displays); new_ordered_items[i].position = i;
const updated_groups = await Promise.all( await db.displays.put(new_ordered_items[i]);
display_groups.map(async (group: DisplayGroup) => ({ }
...group,
data: await Promise.all(group.data.map(update_function)),
}))
);
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[]) { export async function set_new_display_group_order(new_ordered_items: DisplayGroup[]) {
for (const display_id of get(selected_display_ids)) { for (let i = 0; i < new_ordered_items.length; i++) {
const display_ip = get_display_by_id(display_id, get(displays))?.ip; new_ordered_items[i].position = i;
if (display_ip) { await db.display_groups.put(new_ordered_items[i]);
await run_function(display_ip, ...args) }
if (update_screenshot_afterwards) {
await update_screenshot(display_id);
}
}
}
} }
setTimeout(add_testing_displays, 0);
async function add_testing_displays() {
await add_display('127.0.0.1', '00:1A:2B:3C:4D:5E', 'PC', 'host_offline');
add_testing_displays(); // await add_display("192.168.178.111", "D4:81:D7:C0:DF:3C", "Laptop", "host_offline");
function add_testing_displays() {
// const names = ["Vorne Rechts", "Vorne Links", "Vorne Mitte", "Fernseher Rechts", "Fernseher Bühne", "UIUIUIUIUIUIUISEHRLANGERTEXT DER IST WIRKLICH LANG, DER TEXT, so lang, dass er wirklich nirgendswo hinpasst, nichtmal da oben /\\"];
// for (const name of names) {
// add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", name, "Offline");
// }
add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", "PC", "host_offline");
// add_display("192.168.178.111", "D4:81:D7:C0:DF:3C", "Laptop", "host_offline");
} }
+326 -270
View File
@@ -1,317 +1,373 @@
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from 'svelte/store';
import type { Display, FolderElement, TreeElement } from "../types"; import { get_file_primary_key, type Display, type Inode, type TreeElement } from '../types';
import { displays, get_display_by_id } from "./displays"; import { get_display_by_id } from './displays';
import { select, selected_display_ids, selected_file_ids } from "./select"; import { select, selected_display_ids, selected_file_ids } from './select';
import { get_file_data, get_file_tree_data } from "../api_handler"; import { create_folders, get_file_data, get_file_tree_data } from '../api_handler';
import { notifications } from "./notification"; import { deactivate_old_thumbnail_urls, generate_thumbnail } from './thumbnails';
import { CirclePoundSterling } from "lucide-svelte"; import { db, type FileOnDisplay } from '../files_display.db';
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[]>>>({});
// {
// path: {
// display_id: FolderElement[]
// ...
// },
// path2: {
// display_id: FolderElement[]
// ...
// },
// ...
// }
export const current_file_path: Writable<string> = writable<string>('/'); export const current_file_path: Writable<string> = writable<string>('/');
export async function change_file_path(new_path: string) { export async function change_file_path(new_path: string) {
current_file_path.update(() => { current_file_path.update(() => {
return new_path; return new_path;
}); });
selected_file_ids.update(() => { selected_file_ids.update(() => {
return []; return [];
}) });
deactivate_old_thumbnail_urls(); deactivate_old_thumbnail_urls();
for (const display_group of get(displays)) { const displays = await db.displays.toArray();
for (const display of display_group.data) {
const changed_paths = await get_changed_directory_paths(display, new_path); for (const display of displays) {
if (!changed_paths) continue; const changed_paths = await get_changed_directory_paths(display, new_path);
console.log("Update file system from", display.name, ":", changed_paths); if (!changed_paths) continue;
for (const path of changed_paths) { console.log('Update file system from', display.name, ':', changed_paths);
update_folder_elements_recursively(display, path); for (const path of changed_paths) {
} await update_folder_elements_recursively(display, path);
} }
} }
} }
export async function filter_file_selection_for_current_selected_displays() { export async function delete_and_deselect_unique_files_from_display(display_id: string) {
for (const selected_file_id of get(selected_file_ids)) { const files_on_display = await db.files_on_display
if (!get_file_by_id(selected_file_id, get(all_files), get(current_file_path), true)) { .where('display_id')
// file not found in selected displays .equals(display_id)
select(selected_file_ids, selected_file_id, false); .toArray();
} for (const file of files_on_display) {
} await remove_file_from_display(display_id, file.file_primary_key);
}
await remove_all_files_without_display();
}
async function remove_file_from_display(display_id: string, file_primary_key: string) {
const found = await db.files_on_display.get([display_id, file_primary_key]);
if (!found) return;
select(selected_file_ids, file_primary_key, 'deselect');
await db.files_on_display.delete([display_id, file_primary_key]);
}
async function remove_all_files_without_display() {
const existing_file_id_strings: string[] = (await db.files_on_display
.orderBy('file_primary_key')
.uniqueKeys()) as string[];
const existing_file_id_objects: [string, string, number, string][] = existing_file_id_strings.map(
(e) => JSON.parse(e) as [string, string, number, string]
);
await db.files.where('[path+name+size+type]').noneOf(existing_file_id_objects).delete();
} }
export async function update_current_folder_on_selected_displays() { export async function update_current_folder_on_selected_displays() {
selected_file_ids.update(() => { selected_file_ids.update(() => {
return []; return [];
}); });
const current_path = get(current_file_path); 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)); for (const display of await db.displays.where('id').anyOf(get(selected_display_ids)).toArray()) {
if (!display) continue; await update_folder_elements_recursively(display, current_path);
update_folder_elements_recursively(display, current_path); }
}
} }
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[][] { export async function get_missing_colliding_display_ids(
if (!all_files.hasOwnProperty(path)) return [selected_display_ids, []]; file: Inode,
const missing: string[] = []; selected_display_ids: string[]
const colliding: string[] = []; ): Promise<{ missing: string[]; colliding: string[] }> {
Display: const missing: string[] = await get_display_ids_where_file_is_missing(file, selected_display_ids);
for (const selected_display_id of selected_display_ids) {
if (!all_files[path].hasOwnProperty(selected_display_id)) { const colliding: string[] = [];
missing.push(selected_display_id); const colliding_files = await db.files
continue; .where('[path+name]')
} .equals([file.path, file.name])
for (const folder_element of all_files[path][selected_display_id]) { .filter((e) => e.size !== file.size || e.type !== file.type)
if (folder_element.name === file.name) { .toArray();
if (folder_element.hash !== file.hash) { for (const colliding_file of colliding_files) {
colliding.push(selected_display_id); colliding.push(
} ...(await get_display_ids_where_file_is_missing(colliding_file, selected_display_ids))
continue Display; );
} }
}
missing.push(selected_display_id); return { missing, colliding };
}
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[] { async function get_display_ids_where_file_is_missing(
if (!all_files.hasOwnProperty(path)) return selected_display_ids; file: Inode,
const out: string[] = []; selected_display_ids: string[]
for (const selected_display_id of selected_display_ids) { ): Promise<string[]> {
if (!all_files[path].hasOwnProperty(selected_display_id)) { const file_primary_key = get_file_primary_key(file);
out.push(selected_display_id); const files_on_selected_displays = await db.files_on_display
} .where('file_primary_key')
} .equals(file_primary_key)
return out; .filter((e) => selected_display_ids.includes(e.display_id))
.toArray();
return selected_display_ids.filter(
(id) => !files_on_selected_displays.some((item) => item.display_id === id)
);
} }
async function get_changed_directory_paths(display: Display, file_path: string): Promise<string[] | null> { export async function get_displays_where_path_exists(
const current_folder = await get_file_tree_data(display.ip, file_path); path: string,
const directory_strings = get_recursive_changed_directory_paths(display, file_path, current_folder, get(all_files)); selected_display_ids: string[],
if (directory_strings.size === 0) return null; invert: boolean
const directory_strings_array = [...directory_strings]; ): Promise<Display[]> {
return directory_strings_array.filter((e) => (!directory_strings_array.some((f) => (f !== e && f.startsWith(e))))); if (path === '/') return [];
const last_path_part =
path
.slice(0, path.length - 1)
.split('/')
.at(-1) ?? '';
const path_without_last_part = path.slice(0, path.length - (last_path_part.length + 1));
const folders_of_current_path = await db.files
.where('[path+name+type]')
.equals([path_without_last_part, last_path_part, 'inode/directory'])
.first();
if (!folders_of_current_path)
return await db.displays.where('id').anyOf(selected_display_ids).toArray();
const folder_primary_key = get_file_primary_key(folders_of_current_path);
const display_ids = selected_display_ids.filter(async (display_id) => {
const folder_exists = await db.files_on_display.get([display_id, folder_primary_key]);
if (invert) {
return !folder_exists;
} else {
return folder_exists;
}
});
return (await db.displays.bulkGet(display_ids)).filter((e) => e !== undefined);
} }
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> { async function get_changed_directory_paths(
const files_folder: FolderElement[] = files[current_file_path][display.id]; display: Display,
if ((!files_folder || files_folder.length === 0) && (!current_folder_elements || current_folder_elements.length === 0)) { file_path: string
return new Set([]); // no data -> no update needed ): Promise<string[] | null> {
} else if (!files_folder || !current_folder_elements || current_folder_elements.length !== files_folder.length) { const current_folder = await get_file_tree_data(display.ip, file_path);
return new Set([current_file_path]); // existing data does not match new data -> update const directory_strings = await get_recursive_changed_directory_paths(
} display,
file_path,
let has_changed: Set<string> = new Set(); current_folder
for (const tree_folder_element of current_folder_elements) { );
const folder_element = files_folder.find(e => e.name === tree_folder_element.name); if (directory_strings.size === 0) return null;
if (!folder_element || (tree_folder_element.type !== "directory" && folder_element.size !== tree_folder_element.size)) { const directory_strings_array = [...directory_strings];
return new Set([current_file_path]); return directory_strings_array.filter(
} (e) => !directory_strings_array.some((f) => f !== e && f.startsWith(e))
);
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 get_recursive_changed_directory_paths(
const new_folder_elements = await get_file_data(display.ip, file_path); display: Display,
if (new_folder_elements === null) return 0; current_file_path: string,
all_files.update((files: Record<string, Record<string, FolderElement[]>>) => { current_folder_elements: TreeElement[] | null
if (!files.hasOwnProperty(file_path)) { ): Promise<Set<string>> {
files[file_path] = {}; const files_folder: Inode[] = await db.files.where('path').equals(current_file_path).toArray();
} if (
if (!files[file_path].hasOwnProperty(display.id)) { (!files_folder || files_folder.length === 0) &&
files[file_path][display.id] = []; (!current_folder_elements || current_folder_elements.length === 0)
} ) {
return new Set([]); // no data -> no update needed
} else if (
!files_folder ||
!current_folder_elements ||
current_folder_elements.length !== files_folder.length
) {
return new Set([current_file_path]); // existing data does not match new data -> update
}
const existing_folder_elements = files[file_path].hasOwnProperty(display.id) ? files[file_path][display.id] : []; const has_changed: Set<string> = new Set();
for (const tree_folder_element of current_folder_elements) {
const folder_element = files_folder.find((e) => e.name === tree_folder_element.name);
if (
!folder_element ||
(tree_folder_element.type !== 'directory' && folder_element.size !== tree_folder_element.size)
) {
return new Set([current_file_path]);
}
const diff = get_folder_elements_difference(existing_folder_elements, new_folder_elements); if (tree_folder_element.type === 'directory' && tree_folder_element.contents) {
// Generate Thumbnails: const new_file_path = current_file_path + tree_folder_element.name + '/';
setTimeout(async () => { for (const string of await get_recursive_changed_directory_paths(
for (const folder_element of diff.new) { display,
await generate_thumbnail(display.ip, file_path, folder_element); new_file_path,
} tree_folder_element.contents
}, 0) )) {
has_changed.add(string);
files[file_path][display.id].push(...diff.new); }
return remove_folder_elements_recursively(files, display, diff.deleted, file_path); }
}) }
return has_changed;
let folder_size = 0;
for (const element of new_folder_elements) {
if (element.type === 'inode/directory') {
const folder_content_size = await update_folder_elements_recursively(display, file_path + element.name + '/');
folder_size += folder_content_size;
// Update foldersize
all_files.update((files: Record<string, Record<string, FolderElement[]>>) => {
for (const current_folder_element of files[file_path][display.id]) {
if (current_folder_element.id === element.id) {
current_folder_element.size = folder_content_size;
}
}
return files;
})
} else {
folder_size += element.size;
}
}
return folder_size;
} }
function remove_folder_elements_recursively(files: Record<string, Record<string, FolderElement[]>>, display: Display, folder_elements: FolderElement[], file_path: string): Record<string, Record<string, FolderElement[]>> { export async function update_folder_elements_recursively(
if (!files.hasOwnProperty(file_path) || !files[file_path].hasOwnProperty(display.id)) { display: Display,
console.error("File remove path does not exist:", files, display, folder_elements, file_path); file_path: string = '/'
notifications.push("error", "Fehler beim Aktualisieren der Dateien", `File remove path does not exist: ${file_path} display_ip: ${display.ip}`); ): Promise<void> {
return {}; const new_folder_elements = await get_file_data(display.ip, file_path);
} if (!new_folder_elements) return;
for (const folder_element of folder_elements) {
files[file_path][display.id] = files[file_path][display.id].filter((f) => f.id !== folder_element.id);
if (folder_element.type === 'inode/directory') { const existing_file_keys_on_display_in_path: [string, string, number, string][] = (
const new_file_path = file_path + folder_element.name + '/'; await db.files_on_display.where('display_id').equals(display.id).toArray()
if (!files.hasOwnProperty(new_file_path) || !files[new_file_path].hasOwnProperty(display.id)) { ).map((e) => JSON.parse(e.file_primary_key) as [string, string, number, string]);
console.error("File remove path does not exist (recursion):", files, display, folder_elements, file_path, new_file_path); const existing_files_on_display_in_path: Inode[] = await db.files
notifications.push("error", "Fehler beim Aktualisieren der Dateien", `File remove path does not exist (recursion): ${new_file_path} display_ip: ${display.ip}`); .where('[path+name+size+type]')
return {}; .anyOf(existing_file_keys_on_display_in_path)
} .filter((e) => e.path === file_path)
const sub_folder = files[new_file_path][display.id]; .toArray();
remove_folder_elements_recursively(files, display, sub_folder, new_file_path);
}
}
return files; const diff = get_folder_elements_difference(
existing_files_on_display_in_path,
new_folder_elements
);
if (diff.new.length > 0) {
// Add new Folder-Elements
for (const new_element of diff.new) {
await db.files.put(new_element.folder_element);
const file_on_display: FileOnDisplay = {
display_id: display.id,
file_primary_key: get_file_primary_key(new_element.folder_element),
is_loading: false,
percentage: 0,
date_created: new_element.date_created
};
await db.files_on_display.put(file_on_display);
if (new_element.folder_element.type === 'inode/directory') {
await update_folder_elements_recursively(
display,
file_path + new_element.folder_element.name + '/'
);
}
}
// Generate Thumbnails:
setTimeout(async () => {
for (const new_element of diff.new) {
await generate_thumbnail(display.ip, file_path, new_element.folder_element);
}
}, 0);
}
if (diff.deleted.length > 0) {
// Remove old Folder-Elements
for (const old_element of diff.deleted) {
remove_file_from_display(display.id, get_file_primary_key(old_element));
}
await remove_all_files_without_display();
}
} }
function get_folder_elements_difference(old_elements: FolderElement[], new_elements: FolderElement[]): { deleted: FolderElement[], new: FolderElement[] } { function get_folder_elements_difference(
const old_hashes = new Set(old_elements.map(e => e.hash)); old_elements: Inode[],
const new_hashes = new Set(new_elements.map(e => e.hash)); new_elements: { folder_element: Inode; date_created: Date }[]
): { deleted: Inode[]; new: { folder_element: Inode; date_created: Date }[] } {
const old_keys = new Set(old_elements.map((e) => get_file_primary_key(e)));
const new_keys = new Set(new_elements.map((e) => get_file_primary_key(e.folder_element)));
const only_in_old = old_elements.filter(e => !new_hashes.has(e.hash)); const only_in_old = old_elements.filter((e) => !new_keys.has(get_file_primary_key(e)));
const only_in_new = new_elements.filter(e => !old_hashes.has(e.hash)); const only_in_new = new_elements.filter(
return { deleted: only_in_old, new: only_in_new }; (e) => !old_keys.has(get_file_primary_key(e.folder_element))
);
return { deleted: only_in_old, new: only_in_new };
} }
export async function get_current_folder_elements(
current_file_path: string,
selected_display_ids: string[]
): Promise<Inode[]> {
const existing_file_keys_on_selected_displays: [string, string, number, string][] = (
await db.files_on_display.where('display_id').anyOf(selected_display_ids).toArray()
).map((e) => JSON.parse(e.file_primary_key) as [string, string, number, string]);
const existing_files_on_selected_displays_in_path: Inode[] = await db.files
.where('[path+name+size+type]')
.anyOf(existing_file_keys_on_selected_displays)
.filter((e) => e.path === current_file_path)
.toArray();
return sort_files(existing_files_on_selected_displays_in_path);
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 [];
const files_on_display_array = all_files[current_file_path];
const files: FolderElement[] = [];
for (const key of Object.keys(files_on_display_array)) {
if (selected_display_ids.includes(key)) {
FileOnDisplay:
for (const file_on_display of files_on_display_array[key]) {
for (const existing_file of files) {
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;
}
}
files.push({ ...file_on_display });
}
}
}
return sort_files(files);
} }
function sort_files(files: FolderElement[]) { function sort_files(files: Inode[]) {
files.sort((a, b) => { files.sort((a, b) => {
const isDirA = a.type === 'inode/directory'; const isDirA = a.type === 'inode/directory';
const isDirB = b.type === 'inode/directory'; const isDirB = b.type === 'inode/directory';
// Ordner zuerst // Ordner zuerst
if (isDirA && !isDirB) return -1; if (isDirA && !isDirB) return -1;
if (!isDirA && isDirB) return 1; if (!isDirA && isDirB) return 1;
// Danach alphabetisch nach name (case-insensitive) // Danach alphabetisch nach name (case-insensitive)
const nameCompare = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); const nameCompare = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
if (nameCompare !== 0) return nameCompare; if (nameCompare !== 0) return nameCompare;
// Wenn name gleich, absteigend nach date_created return -1;
if (!b.date_created || !a.date_created) return -1; });
return b.date_created.getTime() - a.date_created.getTime(); return files;
});
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 { export async function get_file_by_id(
let current_path_elements: Record<string, FolderElement[]> | undefined = all_files[current_file_path]; file_primary_key: string,
if (!current_path_elements) return null; only_from_selected_displays: boolean = false
if (only_from_selected_displays) { ): Promise<Inode | null> {
current_path_elements = Object.fromEntries( const file = (await db.files.get(JSON.parse(file_primary_key))) ?? null;
Object.entries(current_path_elements).filter(([key]) => get(selected_file_ids).includes(key)) if (!file || !only_from_selected_displays) {
); return file;
} } else {
const all_folder_elements = Object.values(current_path_elements).flat(); const exist_on_selected_display = !!(await db.files_on_display
const found = all_folder_elements.find(el => el.id === file_id); .where('file_primary_key')
if (!found) return null; .equals(file_primary_key)
return found .filter((e) => get(selected_display_ids).includes(e.display_id))
.first());
return exist_on_selected_display ? file : null;
}
} }
export async function run_for_selected_files_on_selected_displays(action: (ip: string, file_names: string[]) => Promise<void>): Promise<void> { export async function run_for_selected_files_on_selected_displays(
const files = get(all_files); action: (ip: string, file_names: string[]) => Promise<void>
const file_path = get(current_file_path); ): Promise<void> {
const folder_elements: FolderElement[] = get(selected_file_ids) for (const display_id of get(selected_display_ids)) {
.map((file_id) => get_file_by_id(file_id, files, file_path)) const file_keys_on_display: [string, string, number, string][] = (
.filter((element) => element !== null); await db.files_on_display.where('display_id').equals(display_id).toArray()
const folder_element_hashs = folder_elements ).map((e) => JSON.parse(e.file_primary_key) as [string, string, number, string]);
.map((folder_element) => folder_element.hash) const file_names_on_display: string[] = (
.filter((hash) => hash !== null); await db.files.where('[path+name+size+type]').anyOf(file_keys_on_display).toArray()
).map((e) => e.name);
for (const display_id of get(selected_display_ids)) { const display = await get_display_by_id(display_id);
if (!files[file_path].hasOwnProperty(display_id)) continue; if (!display) 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);
}
await action(display.ip, 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[] } { export async function create_folder_on_all_selected_displays(
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 folder_name: string,
for (let i = path_parts.length; i > 0; i--) { path: string,
const current_path = '/' + [...path_parts].splice(0, i).join('/') + '/';; selected_display_ids: string[]
if (all_files.hasOwnProperty(current_path)) { ): Promise<void> {
if (all_files[current_path].hasOwnProperty(display_id)) { const path_parts = path
return { existing: current_path, needed: [...path_parts].splice(i) }; .slice(1, path.length - 1)
} .split('/')
} .filter((e) => e.length !== 0);
} let remaining_display_ids = [...selected_display_ids];
return { existing: '/', needed: path_parts };
const getDisplaysForPath = async (currentPath: string): Promise<Display[]> => {
if (currentPath === '/') {
const displays = await db.displays.bulkGet(remaining_display_ids);
return displays.filter((d): d is Display => !!d);
}
return get_displays_where_path_exists(currentPath, remaining_display_ids, false);
};
for (let depth = path_parts.length; depth >= 0 && remaining_display_ids.length; depth--) {
const currentPath = depth === 0 ? '/' : `/${path_parts.slice(0, depth).join('/')}/`;
const displays = await getDisplaysForPath(currentPath);
if (!displays.length) continue;
const folders_to_create = [...path_parts.slice(depth), folder_name];
for (const display of displays) {
await create_folders(display.ip, currentPath, folders_to_create);
remaining_display_ids = remaining_display_ids.filter((id) => id !== display.id);
}
}
} }
@@ -1,4 +1,4 @@
import { get, writable } from "svelte/store"; import { writable } from "svelte/store";
export type Notification = { export type Notification = {
id: number; id: number;
+19 -16
View File
@@ -1,23 +1,26 @@
import { writable, type Writable } from "svelte/store"; import { writable, type Writable } from 'svelte/store';
export const selected_file_ids: Writable<string[]> = writable<string[]>([]); export const selected_file_ids: Writable<string[]> = writable<string[]>([]); // JSON.stringify([string, string, number, string])
export const selected_display_ids: Writable<string[]> = writable<string[]>([]); export const selected_display_ids: Writable<string[]> = writable<string[]>([]);
export function select(
export function select(selected_ids: Writable<string[]>, id: string, new_value: boolean | null = null) { selected_ids: Writable<string[]>,
selected_ids.update((all_ids: string[]) => { id: string,
if (all_ids.includes(id)) { action: 'toggle' | 'select' | 'deselect'
const index = all_ids.indexOf(id); ) {
if (index > -1 && new_value !== true) { selected_ids.update((all_ids: string[]) => {
all_ids.splice(index, 1); if (all_ids.includes(id)) {
} const index = all_ids.indexOf(id);
} else if (new_value !== false) { if (index > -1 && action !== 'select') {
all_ids.push(id); all_ids.splice(index, 1);
} }
return all_ids; } else if (action !== 'deselect') {
}); all_ids.push(id);
}
return all_ids;
});
} }
export function is_selected(id: string, selected_ids: string[]): boolean { export function is_selected(id: string, selected_ids: string[]): boolean {
return selected_ids.includes(id); return selected_ids.includes(id);
} }
+33 -28
View File
@@ -1,38 +1,43 @@
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from 'svelte/store';
import { get_thumbnail_blob } from "../api_handler"; import { get_thumbnail_blob } from '../api_handler';
import { type FolderElement } from "../types"; import { type Inode } from '../types';
import { db, type ThumbnailBlobDBEntry } from "../indexdb/file_thumbnails.db"; import { db } from '../files_display.db';
import { get_file_type } from "../utils"; import { get_file_type } from '../utils';
export const active_thumbnail_urls: Writable<string[]> = writable<string[]>([]); export const active_thumbnail_urls: Writable<string[]> = writable<string[]>([]);
export async function generate_thumbnail(display_ip: string, path: string, folder_element: FolderElement): Promise<void> { export async function generate_thumbnail(
const supported_file_type = get_file_type(folder_element); display_ip: string,
if (!supported_file_type || !folder_element.hash) return; path: string,
const hash: string = folder_element.hash; folder_element: Inode
if (await db.thumbnail_blobs.get(hash)) return; ): Promise<void> {
const supported_file_type = get_file_type(folder_element);
if (!supported_file_type) return;
const thumbnail_blob = await get_thumbnail_blob(display_ip, path + folder_element.name); const thumbnail_blob = await get_thumbnail_blob(display_ip, path + folder_element.name);
if (!thumbnail_blob) return; if (!thumbnail_blob) return;
await db.thumbnail_blobs.add({ hash: hash, blob: thumbnail_blob });
folder_element.thumbnail = thumbnail_blob;
await db.files.put(folder_element); // save
} }
export async function get_thumbnail_url(hash: string | null): Promise<string | null> { export async function get_thumbnail_url(file: Inode): Promise<string | null> {
if (hash === null) return null; if (!file.thumbnail) return null;
const thumbnail_blob = await db.thumbnail_blobs.get(hash) as ThumbnailBlobDBEntry; const new_url = URL.createObjectURL(file.thumbnail);
if (!thumbnail_blob) return null; active_thumbnail_urls.update((current: string[]) => {
const new_url = URL.createObjectURL(thumbnail_blob.blob); current.push(new_url);
active_thumbnail_urls.update((current: string[]) => { return current;
current.push(new_url); });
return current; return new_url;
})
return new_url;
} }
export function deactivate_old_thumbnail_urls() { export function deactivate_old_thumbnail_urls() {
const current_urls = get(active_thumbnail_urls); const current_urls = get(active_thumbnail_urls);
for (const url of current_urls) { for (const url of current_urls) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
active_thumbnail_urls.update(() => { return []; }) active_thumbnail_urls.update(() => {
return [];
});
} }
Regular → Executable
+75 -67
View File
@@ -1,89 +1,97 @@
import { FileBox, FileImage, FileText, FileVideoCamera, ImagePlay, type X } from "lucide-svelte"; import { FileBox, FileImage, FileText, FileVideoCamera, ImagePlay, type X } from 'lucide-svelte';
import type { Snippet } from "svelte"; import type { Snippet } from 'svelte';
export type RequestResponse = { export type RequestResponse = {
ok: boolean, ok: boolean;
http_code?: number http_code?: number;
blob?: Blob, blob?: Blob;
json?: any, json?: Record<string, unknown>;
} };
export type ShellCommandResponse = { export type ShellCommandResponse = {
stdout: string, stdout: string;
stderr: string, stderr: string;
exitCode: number, exitCode: number;
} };
export type SupportedFileType = { export type SupportedFileType = {
display_name: string; display_name: string;
mime_type: string; mime_type: string;
}; };
export const supported_file_type_icon: Record<string, typeof X> = { export const supported_file_type_icon: Record<string, typeof X> = {
'MP4': FileVideoCamera, MP4: FileVideoCamera,
'JPG': FileImage, JPG: FileImage,
'PNG': FileImage, PNG: FileImage,
'GIF': ImagePlay, GIF: ImagePlay,
'PPTX': FileBox, PPTX: FileBox,
'ODP': FileBox, ODP: FileBox,
'PDF': FileText PDF: FileText
} };
export type FolderElement = { export type Inode = {
id: string; path: string;
hash: string; name: string;
name: string; size: number;
type: string; type: string;
date_created: Date | null; date_created: Date;
size: number; thumbnail: Blob | null;
};
export function get_file_primary_key(file: Inode): string {
return JSON.stringify([file.path, file.name, file.size, file.type]);
} }
export type TreeElement = { export type TreeElement = {
contents?: TreeElement[]; contents?: TreeElement[];
type: "file" | "directory"; type: 'file' | 'directory';
name: string; name: string;
size: number; size: number;
}
export type Display = {
id: string;
ip: string;
preview_url: string | null;
preview_timeout_id: number | null;
mac: string | null;
name: string;
status: DisplayStatus;
}
export type DisplayGroup = {
id: string;
data: Display[];
}; };
export type Display = {
id: string;
ip: string;
mac: string | null;
position: number;
preview: PreviewObject;
group_id: string;
name: string;
status: DisplayStatus;
};
export type DisplayGroup = {
id: string;
position: number;
};
export type PreviewObject = {
currently_updating: boolean;
url: string | null;
};
export type MenuOption = { export type MenuOption = {
icon?: typeof X; icon?: typeof X;
name: string; name: string;
class?: string; class?: string;
on_select?: () => void; on_select?: () => void | Promise<void>;
disabled?: boolean; disabled?: boolean;
} };
export type PopupContent = { export type PopupContent = {
open: boolean; open: boolean;
snippet: Snippet<[string]> | null; snippet: Snippet<[string]> | null;
snippet_arg?: string; snippet_arg?: string;
title?: string; title?: string;
title_class?: string; title_class?: string;
title_icon?: typeof X | null; title_icon?: typeof X | null;
window_class?: string; window_class?: string;
closable?: boolean; closable?: boolean;
} };
export type DisplayStatus = "host_offline" | "app_offline" | "app_online" | null; export type DisplayStatus = 'host_offline' | 'app_offline' | 'app_online' | null;
export function to_display_status(value: string): DisplayStatus { export function to_display_status(value: string): DisplayStatus {
return ["host_offline", "app_offline", "app_online"].includes(value) return ['host_offline', 'app_offline', 'app_online'].includes(value)
? (value as DisplayStatus) ? (value as DisplayStatus)
: null; : null;
} }
+56 -55
View File
@@ -1,82 +1,83 @@
import type { DisplayStatus, FolderElement, SupportedFileType } from "./types"; import type { DisplayStatus, Inode, SupportedFileType } from './types';
import supported_file_types_json from './../../../../shared/supported_file_types.json'; import supported_file_types_json from './../../../../shared/supported_file_types.json';
const supported_file_types: Record<string, SupportedFileType> = supported_file_types_json as Record<string, SupportedFileType>; const supported_file_types: Record<string, SupportedFileType> = supported_file_types_json as Record<
string,
SupportedFileType
>;
export function get_file_type(file: FolderElement): SupportedFileType | null { export function get_file_type(file: Inode): SupportedFileType | null {
for (const key of Object.keys(supported_file_types)) { for (const key of Object.keys(supported_file_types)) {
if (file.type === supported_file_types[key].mime_type) { if (file.type === supported_file_types[key].mime_type) {
return supported_file_types[key]; return supported_file_types[key];
} }
} }
// Fallback: // Fallback:
const extension = file.name.split('.').pop(); const extension = file.name.split('.').pop();
if (extension) { if (extension) {
if (Object.keys(supported_file_types).includes('.' + extension)) { if (Object.keys(supported_file_types).includes('.' + extension)) {
return supported_file_types['.' + extension]; return supported_file_types['.' + extension];
} }
} }
return null; return null;
} }
export function get_uuid(): string { 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 { 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)
if (size === 0) return "0 B"; return toFixed === null ? 'versch.' : 'Verschiedene Größen auf verschiedenen Bildschirmen';
if (size === 0) return '0 B';
const k = 1024; const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"]; const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(size) / Math.log(k)); const i = Math.floor(Math.log(size) / Math.log(k));
const value = size / Math.pow(k, i); 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('.', ',');
} }
export async function image_content_hash(blob: Blob, size = 32): Promise<number> { export async function image_content_hash(blob: Blob, size = 32): Promise<number> {
// Blob → ImageBitmap (GPU-dekodiert, superschnell) // Blob → ImageBitmap (GPU-dekodiert, superschnell)
const bitmap = await createImageBitmap(blob); const bitmap = await createImageBitmap(blob);
// OffscreenCanvas ist sehr schnell (kein Layout nötig) // OffscreenCanvas ist sehr schnell (kein Layout nötig)
const canvas = new OffscreenCanvas(size, size); const canvas = new OffscreenCanvas(size, size);
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0, size, size); ctx.drawImage(bitmap, 0, 0, size, size);
// Pixel-Daten holen // Pixel-Daten holen
const { data } = ctx.getImageData(0, 0, size, size); const { data } = ctx.getImageData(0, 0, size, size);
// Einfacher, schneller Integer-Hash (FNV-1a) // Einfacher, schneller Integer-Hash (FNV-1a)
let hash = 2166136261; let hash = 2166136261;
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
hash ^= data[i]; hash ^= data[i];
hash = Math.imul(hash, 16777619); hash = Math.imul(hash, 16777619);
} }
bitmap.close(); // GPU-Ressourcen freigeben bitmap.close(); // GPU-Ressourcen freigeben
return hash >>> 0; // unsigned int return hash >>> 0; // unsigned int
} }
export function is_valid_name(input: string): boolean { export function is_valid_name(input: string): boolean {
return /^[\p{L}\p{N}\p{M}\-_.+,()[\]{}@!§$%&=~^ ]+$/u.test(input); return /^[\p{L}\p{N}\p{M}\-_.+,()[\]{}@!§$%&=~^ ]+$/u.test(input);
} }
export function display_status_to_info(status: DisplayStatus): string { export function display_status_to_info(status: DisplayStatus): string {
switch (status) { switch (status) {
case 'app_online': case 'app_online':
return 'Online'; return 'Online';
case 'app_offline': case 'app_offline':
return 'Lädt'; return 'Lädt';
case 'host_offline': case 'host_offline':
return 'Offline'; return 'Offline';
case null: case null:
return '???'; return '???';
} }
} }