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
>{all_selected($displays, $selected_display_ids)
? 'Alle abwählen'
: 'Alle auswählen'}</span
> >
<span>{$all_groups_selected || false ? '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}
/> />
+50 -53
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;
const path_data = get_longest_existing_path_and_needed_parts(
$current_file_path, $current_file_path,
display_id, $selected_display_ids
$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
+110 -53
View File
@@ -1,6 +1,12 @@
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' };
@@ -19,8 +25,8 @@ export async function send_keyboard_input(ip: string, key: string): Promise<void
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);
} }
@@ -36,7 +42,10 @@ export async function show_html(ip: string, html: string) {
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(
ip: string,
path: string
): Promise<{ folder_element: Inode; date_created: Date }[] | null> {
interface FileInfo { interface FileInfo {
name: string; name: string;
type: string; type: string;
@@ -52,54 +61,54 @@ 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()
.split('\n')
.filter(Boolean) .filter(Boolean)
.map((line: string) => JSON.parse(line) as FileInfo); .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
};
const folder_element: FolderElement = {
id: get_uuid(),
hash: JSON.stringify(truncated),
name: response_element.name.slice(2), // remove "./" name: response_element.name.slice(2), // remove "./"
type: response_element.type, type: response_element.type,
date_created: new Date(response_element.created),
size: Number(response_element.size), size: Number(response_element.size),
thumbnail: null
}; };
folder_element_list.push(folder_element); folder_element_list.push({ folder_element, date_created: new Date(response_element.created) });
}
} }
return folder_element_list; 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(
ip: string,
path: string,
folder_names: string[]
): Promise<void> {
let command = `cd ".${path}"`; let command = `cd ".${path}"`;
for (const part of folder_names) { for (const part of folder_names) {
@@ -112,7 +121,12 @@ export async function create_folders(ip: string, path: string, folder_names: str
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(
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 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);
@@ -121,7 +135,11 @@ export async function rename_file(ip: string, path: string, old_file_name: strin
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(
ip: string,
current_path: string,
file_names: string[]
): Promise<void> {
let command: string = `cd ".${current_path}"`; let command: string = `cd ".${current_path}"`;
for (const file_name of file_names) { for (const file_name of file_names) {
command += ` && rm -r "${file_name}"`; command += ` && rm -r "${file_name}"`;
@@ -132,7 +150,6 @@ export async function delete_files(ip: string, current_path: string, file_names:
handle_shell_error(ip, json_response, command, true); 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',
@@ -145,31 +162,53 @@ export async function show_blackscreen(ip: string): Promise<void> {
} }
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,
`/file/preview${path_to_file}`,
{ method: 'GET' },
true,
[415]
);
if (!raw_response.ok || !raw_response.blob) return null;
return raw_response.blob; 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,
options: { method: string; headers?: Record<string, string>; body?: string },
log_in_debug: boolean = false,
supress_error_handling_http_codes: number[] = []
): Promise<RequestResponse> {
const url = `http://${ip}:1323/api${api_route}`; const url = `http://${ip}:1323/api${api_route}`;
return await request(url, options, log_in_debug, supress_error_handling_http_codes); 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(
api_route: string,
options: { method: string; headers?: Record<string, string>; body?: string }
): Promise<RequestResponse> {
const url = `${window.location.origin}/api${api_route}`; const url = `${window.location.origin}/api${api_route}`;
return await request(url, options); return await request(url, options);
} }
async function request(
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> { 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 { try {
const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`; const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`;
if (log_in_debug) { if (log_in_debug) {
@@ -179,13 +218,13 @@ async function request(url: string, options: { method: string, headers?: Record<
} }
const response = await fetch(url + cache_buster, options); const response = await fetch(url + cache_buster, options);
if (response.ok || supress_error_handling_http_codes.includes(response.status)) { if (response.ok || supress_error_handling_http_codes.includes(response.status)) {
const contentType = response.headers.get("content-type") || ""; const contentType = response.headers.get('content-type') || '';
let request_response: RequestResponse; let request_response: RequestResponse;
if (!contentType.includes("application/json")) { if (!contentType.includes('application/json')) {
const blob: Blob = await response.blob(); const blob: Blob = await response.blob();
request_response = { ok: response.ok, http_code: response.status, blob: blob }; request_response = { ok: response.ok, http_code: response.status, blob: blob };
} else { } else {
const json: Object = await response.json(); const json: Record<string, unknown> = await response.json();
request_response = { ok: response.ok, http_code: response.status, json: json }; request_response = { ok: response.ok, http_code: response.status, json: json };
} }
if (log_in_debug) { if (log_in_debug) {
@@ -200,40 +239,58 @@ async function request(url: string, options: { method: string, headers?: Record<
try { try {
const json: { description: string } = await response.json(); const json: { description: string } = await response.json();
error_description = json.description; error_description = json.description;
} catch (error_on_json_parsing: any) { } catch (error_on_json_parsing: unknown) {
error_description = `unknown error: ${error_on_json_parsing}`; error_description = `unknown error: ${error_on_json_parsing}`;
} }
console.error(url, error_description); console.error(url, error_description);
notifications.push("error", `Fehler ${response.status} bei API-Anfrage`, `${url}\nFehler: ${error_description}`); notifications.push(
} catch (error: any) { 'error',
`Fehler ${response.status} bei API-Anfrage`,
`${url}\nFehler: ${error_description}`
);
} catch (error: unknown) {
if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) { if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) {
console.log("Request failed - Is the targeted device online?") console.log('Request failed - Is the targeted device online?');
} else { } else {
console.error(url, error); console.error(url, error);
notifications.push("error", `Fataler Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`); notifications.push('error', `Fataler Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
} }
} }
return { ok: false }; 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,
command_includs_cd: boolean
): boolean {
if (shell_response.exitCode !== 0) { if (shell_response.exitCode !== 0) {
if (command_includs_cd && shell_response.stderr && /bash: line \d+: cd: .+: No such file or directory/.test(shell_response.stderr)) { if (
console.log("current file_path does not exist on display:", ip); command_includs_cd &&
shell_response.stderr &&
/bash: line \d+: cd: .+: No such file or directory/.test(shell_response.stderr)
) {
console.log('current file_path does not exist on display:', ip);
return true; return true;
} }
console.error(shell_response); console.error(shell_response);
notifications.push("error", `Fehler ${shell_response.exitCode} in API-Shell`, `${ip}\n${shell_command}\nFehler: ${shell_response.stderr}`); notifications.push(
'error',
`Fehler ${shell_response.exitCode} in API-Shell`,
`${ip}\n${shell_command}\nFehler: ${shell_response.stderr}`
);
return true; return true;
} }
if (shell_response.stdout.trim() === '') return true; if (shell_response.stdout.trim() === '') return true;
return false; return false;
} }
async function run_shell_command(ip: string, command: string, log_in_debug: boolean = false): Promise<RequestResponse> { async function run_shell_command(
ip: string,
command: string,
log_in_debug: boolean = false
): Promise<RequestResponse> {
const options = { const options = {
method: 'PATCH', method: 'PATCH',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
+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
+18 -18
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 db.files_on_display.clear();
await update_all_display_status(); await update_all_display_status();
await setInterval(update_all_display_status, update_display_status_interval_seconds * 1000); 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();
for (const display of all_displays) {
const new_status = await ping_ip(display.ip); const new_status = await ping_ip(display.ip);
if (new_status === null && display.status !== null) return display; if (new_status === null && display.status !== null) continue;
if (new_status === "app_online" && display.status !== "app_online") { if (new_status === 'app_online' && display.status !== 'app_online') {
await on_display_start(display); on_display_start(display);
}
if (new_status !== display.status) {
display.status = new_status;
await db.displays.put(display); // save
}
} }
return { ...display, status: new_status, };
});
console.log("Display Status updated")
} }
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);
} }
+149 -144
View File
@@ -1,54 +1,82 @@
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[]
): Promise<boolean> {
const displays_of_group: Display[] = await db.displays
.where('group_id')
.equals(display_group_id)
.toArray();
if (displays_of_group.length === 0) return false;
for (const display of displays_of_group) {
if (!is_selected(display.id, current_selected_displays)) { if (!is_selected(display.id, current_selected_displays)) {
return false; return false;
} }
@@ -56,136 +84,113 @@ export function all_displays_of_group_selected(display_group: DisplayGroup, curr
return true; 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
) {
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');
} }
filter_file_selection_for_current_selected_displays();
} }
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) {
return display;
}
}
}
return null;
}
display.preview.currently_updating = true;
await db.displays.update(display.id, { preview: display.preview });
export function add_empty_display_group() { let last_hash: number | null = null;
displays.update((displays: DisplayGroup[]) => {
displays.push({
id: get_uuid(),
data: [],
});
return displays;
});
}
let retry_count = initial_retry_count;
while (retry_count > 0) {
retry_count -= 1;
export function remove_empty_display_groups() { const new_blob = await get_screenshot(display.ip);
displays.update((displays: DisplayGroup[]) => {
for (let i = displays.length - 1; i >= 0; i--) {
if (displays[i].data.length === 0) {
displays.splice(i, 1);
}
}
return displays;
});
}
export async function update_screenshot(display_id: string, check_type: "first_check" | "last_check_different" | "last_check_same" = "first_check") {
const display_ip = get_display_by_id(display_id, get(displays))?.ip;
if (!display_ip) return;
const new_blob = await get_screenshot(display_ip);
if (!new_blob) { if (!new_blob) {
update_displays_with_map((display: Display) => { display.preview = { currently_updating: false, url: null };
if (display.id !== display_id) return display; await db.displays.update(display.id, { preview: display.preview });
return { ...display, preview_url: null, preview_timeout_id: null };
})
return; 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 new_hash = await image_content_hash(new_blob);
const old_hash = await image_content_hash(old_blob); if (last_hash !== new_hash) {
update_needed = old_hash !== new_hash; // if different -> update if (display.preview.url) {
} URL.revokeObjectURL(display.preview.url);
} }
let new_preview_timeout_id: number | null = null; last_hash = new_hash;
if (update_needed || check_type === "last_check_different") { display.preview.url = URL.createObjectURL(new_blob);
new_preview_timeout_id = setTimeout(async () => { await update_screenshot(display_id, update_needed ? "last_check_different" : "last_check_same") }, 2 * 1000); await db.displays.update(display.id, { preview: display.preview });
}
if (display?.preview_timeout_id) { retry_count = initial_retry_count;
clearInterval(display.preview_timeout_id);
} }
if (update_needed) { await new Promise((resolve) => setTimeout(resolve, 2000)); // sleep 2s
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 };
})
} }
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 async function update_displays_with_map(update_function: (display: Display) => Display | Promise<Display>) { run_function: (ip: string, ...args: T) => void | Promise<void>,
const display_groups = get(displays); update_screenshot_afterwards: boolean,
const updated_groups = await Promise.all( ...args: T
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[]) {
for (const display_id of get(selected_display_ids)) { for (const display_id of get(selected_display_ids)) {
const display_ip = get_display_by_id(display_id, get(displays))?.ip; const display_ip = (await get_display_by_id(display_id))?.ip;
if (display_ip) { if (display_ip) {
await run_function(display_ip, ...args) await run_function(display_ip, ...args);
if (update_screenshot_afterwards) { if (update_screenshot_afterwards) {
await update_screenshot(display_id); await screenshot_loop(display_id);
} }
} }
} }
} }
export async function get_display_groups(): Promise<DisplayGroup[]> {
return await db.display_groups.orderBy('position').toArray();
}
add_testing_displays();
function add_testing_displays() { export async function get_display_ids_in_group(display_group_id: string): Promise<Display[]> {
// 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 /\\"]; const displays: Display[] = await db.displays
// for (const name of names) { .where('group_id')
// add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", name, "Offline"); .equals(display_group_id)
// } .sortBy('position');
return displays;
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");
export async function set_new_display_order(new_ordered_items: Display[]) {
for (let i = 0; i < new_ordered_items.length; i++) {
new_ordered_items[i].position = i;
await db.displays.put(new_ordered_items[i]);
}
}
export async function set_new_display_group_order(new_ordered_items: DisplayGroup[]) {
for (let i = 0; i < new_ordered_items.length; i++) {
new_ordered_items[i].position = i;
await db.display_groups.put(new_ordered_items[i]);
}
}
setTimeout(add_testing_displays, 0);
async function add_testing_displays() {
await add_display('127.0.0.1', '00:1A:2B:3C:4D:5E', 'PC', 'host_offline');
// await add_display("192.168.178.111", "D4:81:D7:C0:DF:3C", "Laptop", "host_offline");
} }
+268 -212
View File
@@ -1,57 +1,61 @@
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) {
for (const display of displays) {
const changed_paths = await get_changed_directory_paths(display, new_path); const changed_paths = await get_changed_directory_paths(display, new_path);
if (!changed_paths) continue; if (!changed_paths) continue;
console.log("Update file system from", display.name, ":", changed_paths); console.log('Update file system from', display.name, ':', changed_paths);
for (const path of changed_paths) { for (const path of changed_paths) {
update_folder_elements_recursively(display, path); 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() {
@@ -59,73 +63,134 @@ export async function update_current_folder_on_selected_displays() {
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[]
): Promise<{ missing: string[]; colliding: string[] }> {
const missing: string[] = await get_display_ids_where_file_is_missing(file, selected_display_ids);
const colliding: string[] = []; const colliding: string[] = [];
Display: const colliding_files = await db.files
for (const selected_display_id of selected_display_ids) { .where('[path+name]')
if (!all_files[path].hasOwnProperty(selected_display_id)) { .equals([file.path, file.name])
missing.push(selected_display_id); .filter((e) => e.size !== file.size || e.type !== file.type)
continue; .toArray();
for (const colliding_file of colliding_files) {
colliding.push(
...(await get_display_ids_where_file_is_missing(colliding_file, selected_display_ids))
);
} }
for (const folder_element of all_files[path][selected_display_id]) {
if (folder_element.name === file.name) { return { missing, colliding };
if (folder_element.hash !== file.hash) {
colliding.push(selected_display_id);
}
continue Display;
}
}
missing.push(selected_display_id);
}
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(
path: string,
selected_display_ids: string[],
invert: boolean
): Promise<Display[]> {
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);
}
async function get_changed_directory_paths(
display: Display,
file_path: string
): Promise<string[] | null> {
const current_folder = await get_file_tree_data(display.ip, file_path); const current_folder = await get_file_tree_data(display.ip, file_path);
const directory_strings = get_recursive_changed_directory_paths(display, file_path, current_folder, get(all_files)); const directory_strings = await get_recursive_changed_directory_paths(
display,
file_path,
current_folder
);
if (directory_strings.size === 0) return null; if (directory_strings.size === 0) return null;
const directory_strings_array = [...directory_strings]; const directory_strings_array = [...directory_strings];
return directory_strings_array.filter((e) => (!directory_strings_array.some((f) => (f !== e && f.startsWith(e))))); return directory_strings_array.filter(
(e) => !directory_strings_array.some((f) => f !== e && f.startsWith(e))
);
} }
function get_recursive_changed_directory_paths(display: Display, current_file_path: string, current_folder_elements: TreeElement[] | null, files: Record<string, Record<string, FolderElement[]>>): Set<string> { async function get_recursive_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)) { current_file_path: string,
current_folder_elements: TreeElement[] | null
): Promise<Set<string>> {
const files_folder: Inode[] = await db.files.where('path').equals(current_file_path).toArray();
if (
(!files_folder || files_folder.length === 0) &&
(!current_folder_elements || current_folder_elements.length === 0)
) {
return new Set([]); // no data -> no update needed return new Set([]); // no data -> no update needed
} else if (!files_folder || !current_folder_elements || current_folder_elements.length !== files_folder.length) { } 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 return new Set([current_file_path]); // existing data does not match new data -> update
} }
let has_changed: Set<string> = new Set(); const has_changed: Set<string> = new Set();
for (const tree_folder_element of current_folder_elements) { for (const tree_folder_element of current_folder_elements) {
const folder_element = files_folder.find(e => e.name === tree_folder_element.name); 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)) { if (
!folder_element ||
(tree_folder_element.type !== 'directory' && folder_element.size !== tree_folder_element.size)
) {
return new Set([current_file_path]); return new Set([current_file_path]);
} }
if (tree_folder_element.type === "directory" && tree_folder_element.contents) { if (tree_folder_element.type === 'directory' && tree_folder_element.contents) {
const new_file_path = current_file_path + tree_folder_element.name + '/'; 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)) { for (const string of await get_recursive_changed_directory_paths(
display,
new_file_path,
tree_folder_element.contents
)) {
has_changed.add(string); has_changed.add(string);
} }
} }
@@ -133,116 +198,95 @@ function get_recursive_changed_directory_paths(display: Display, current_file_pa
return has_changed; return has_changed;
} }
export async function update_folder_elements_recursively(display: Display, file_path: string = '/'): Promise<number> { export async function update_folder_elements_recursively(
display: Display,
file_path: string = '/'
): Promise<void> {
const new_folder_elements = await get_file_data(display.ip, file_path); const new_folder_elements = await get_file_data(display.ip, file_path);
if (new_folder_elements === null) return 0; if (!new_folder_elements) return;
all_files.update((files: Record<string, Record<string, FolderElement[]>>) => {
if (!files.hasOwnProperty(file_path)) { const existing_file_keys_on_display_in_path: [string, string, number, string][] = (
files[file_path] = {}; await db.files_on_display.where('display_id').equals(display.id).toArray()
).map((e) => JSON.parse(e.file_primary_key) as [string, string, number, string]);
const existing_files_on_display_in_path: Inode[] = await db.files
.where('[path+name+size+type]')
.anyOf(existing_file_keys_on_display_in_path)
.filter((e) => e.path === file_path)
.toArray();
const diff = get_folder_elements_difference(
existing_files_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 + '/'
);
} }
if (!files[file_path].hasOwnProperty(display.id)) {
files[file_path][display.id] = [];
} }
const existing_folder_elements = files[file_path].hasOwnProperty(display.id) ? files[file_path][display.id] : [];
const diff = get_folder_elements_difference(existing_folder_elements, new_folder_elements);
// Generate Thumbnails: // Generate Thumbnails:
setTimeout(async () => { setTimeout(async () => {
for (const folder_element of diff.new) { for (const new_element of diff.new) {
await generate_thumbnail(display.ip, file_path, folder_element); await generate_thumbnail(display.ip, file_path, new_element.folder_element);
} }
}, 0) }, 0);
files[file_path][display.id].push(...diff.new);
return remove_folder_elements_recursively(files, display, diff.deleted, file_path);
})
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;
} }
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));
} }
return files; await remove_all_files_without_display();
})
} 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[]>> { function get_folder_elements_difference(
if (!files.hasOwnProperty(file_path) || !files[file_path].hasOwnProperty(display.id)) { old_elements: Inode[],
console.error("File remove path does not exist:", files, display, folder_elements, file_path); new_elements: { folder_element: Inode; date_created: Date }[]
notifications.push("error", "Fehler beim Aktualisieren der Dateien", `File remove path does not exist: ${file_path} display_ip: ${display.ip}`); ): { deleted: Inode[]; new: { folder_element: Inode; date_created: Date }[] } {
return {}; 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)));
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 only_in_old = old_elements.filter((e) => !new_keys.has(get_file_primary_key(e)));
const new_file_path = file_path + folder_element.name + '/'; const only_in_new = new_elements.filter(
if (!files.hasOwnProperty(new_file_path) || !files[new_file_path].hasOwnProperty(display.id)) { (e) => !old_keys.has(get_file_primary_key(e.folder_element))
console.error("File remove path does not exist (recursion):", files, display, folder_elements, file_path, new_file_path); );
notifications.push("error", "Fehler beim Aktualisieren der Dateien", `File remove path does not exist (recursion): ${new_file_path} display_ip: ${display.ip}`);
return {};
}
const sub_folder = files[new_file_path][display.id];
remove_folder_elements_recursively(files, display, sub_folder, new_file_path);
}
}
return files;
}
function get_folder_elements_difference(old_elements: FolderElement[], new_elements: FolderElement[]): { deleted: FolderElement[], new: FolderElement[] } {
const old_hashes = new Set(old_elements.map(e => e.hash));
const new_hashes = new Set(new_elements.map(e => e.hash));
const only_in_old = old_elements.filter(e => !new_hashes.has(e.hash));
const only_in_new = new_elements.filter(e => !old_hashes.has(e.hash));
return { deleted: only_in_old, new: only_in_new }; 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';
@@ -255,63 +299,75 @@ function sort_files(files: FolderElement[]) {
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 exist_on_selected_display = !!(await db.files_on_display
.where('file_primary_key')
.equals(file_primary_key)
.filter((e) => get(selected_display_ids).includes(e.display_id))
.first());
return exist_on_selected_display ? file : null;
} }
const all_folder_elements = Object.values(current_path_elements).flat();
const found = all_folder_elements.find(el => el.id === file_id);
if (!found) return null;
return found
} }
export async function run_for_selected_files_on_selected_displays(action: (ip: string, file_names: string[]) => Promise<void>): Promise<void> { 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)
.map((file_id) => get_file_by_id(file_id, files, file_path))
.filter((element) => element !== null);
const folder_element_hashs = folder_elements
.map((folder_element) => folder_element.hash)
.filter((hash) => hash !== null);
for (const display_id of get(selected_display_ids)) { for (const display_id of get(selected_display_ids)) {
if (!files[file_path].hasOwnProperty(display_id)) continue; const file_keys_on_display: [string, string, number, string][] = (
const files_on_display = files[file_path][display_id]; await db.files_on_display.where('display_id').equals(display_id).toArray()
).map((e) => JSON.parse(e.file_primary_key) as [string, string, number, string]);
const file_names_on_display: string[] = (
await db.files.where('[path+name+size+type]').anyOf(file_keys_on_display).toArray()
).map((e) => e.name);
const selected_file_names_on_display: string[] = files_on_display const display = await get_display_by_id(display_id);
.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; 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];
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);
} }
} }
}
return { existing: '/', needed: path_parts };
} }
@@ -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;
+9 -6
View File
@@ -1,17 +1,20 @@
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[]>,
id: string,
action: 'toggle' | 'select' | 'deselect'
) {
selected_ids.update((all_ids: string[]) => { selected_ids.update((all_ids: string[]) => {
if (all_ids.includes(id)) { if (all_ids.includes(id)) {
const index = all_ids.indexOf(id); const index = all_ids.indexOf(id);
if (index > -1 && new_value !== true) { if (index > -1 && action !== 'select') {
all_ids.splice(index, 1); all_ids.splice(index, 1);
} }
} else if (new_value !== false) { } else if (action !== 'deselect') {
all_ids.push(id); all_ids.push(id);
} }
return all_ids; return all_ids;
+22 -17
View File
@@ -1,31 +1,34 @@
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(
display_ip: string,
path: string,
folder_element: Inode
): Promise<void> {
const supported_file_type = get_file_type(folder_element); const supported_file_type = get_file_type(folder_element);
if (!supported_file_type || !folder_element.hash) return; if (!supported_file_type) return;
const hash: string = folder_element.hash;
if (await db.thumbnail_blobs.get(hash)) return;
const thumbnail_blob = await get_thumbnail_blob(display_ip, path + folder_element.name); 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;
const new_url = URL.createObjectURL(thumbnail_blob.blob);
active_thumbnail_urls.update((current: string[]) => { active_thumbnail_urls.update((current: string[]) => {
current.push(new_url); current.push(new_url);
return current; return current;
}) });
return new_url; return new_url;
} }
@@ -34,5 +37,7 @@ export function deactivate_old_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
+44 -36
View File
@@ -1,73 +1,81 @@
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;
type: string;
date_created: Date | null;
size: number; size: number;
type: string;
date_created: Date;
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 = { export type Display = {
id: string; id: string;
ip: string; ip: string;
preview_url: string | null;
preview_timeout_id: number | null;
mac: string | null; mac: string | null;
position: number;
preview: PreviewObject;
group_id: string;
name: string; name: string;
status: DisplayStatus; status: DisplayStatus;
} };
export type DisplayGroup = { export type DisplayGroup = {
id: string; id: string;
data: Display[]; 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;
@@ -78,12 +86,12 @@ export type PopupContent = {
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;
} }
+11 -10
View File
@@ -1,9 +1,12 @@
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];
@@ -24,11 +27,12 @@ export function get_uuid(): string {
} }
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);
@@ -38,14 +42,13 @@ export function get_file_size_display_string(size: number, toFixed: number | nul
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
@@ -62,8 +65,6 @@ export async function image_content_hash(blob: Blob, size = 32): Promise<number>
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);
} }