chore(control): add delete file and create folder action

This commit is contained in:
E44
2025-11-22 23:30:36 +01:00
parent 4cf2633bc1
commit efb99cbac0
4 changed files with 313 additions and 116 deletions
+126 -25
View File
@@ -16,17 +16,30 @@
import Button from './Button.svelte';
import PathBar from './PathBar.svelte';
import { selected_display_ids, selected_file_ids } from '../ts/stores/select';
import { all_files, current_file_path, get_current_folder_elements } from '../ts/stores/files';
import {
all_files,
current_file_path,
get_current_folder_elements,
get_display_ids_where_file_is_missing,
get_display_ids_where_path_does_not_exist,
get_file_from_id,
get_longest_existing_path_and_needed_parts,
run_for_selected_files_on_selected_displays,
update_current_folder_on_selected_displays
} from '../ts/stores/files';
import { slide } from 'svelte/transition';
import FolderElementObject from './FolderElementObject.svelte';
import PopUp from './PopUp.svelte';
import type { PopupContent } from '../ts/types';
import TextInput from './TextInput.svelte';
import { is_valid_name } from '../ts/utils';
import { displays, get_display_by_id, run_on_all_selected_displays } from '../ts/stores/displays';
import { create_folders, delete_files } from '../ts/api_handler';
import { get } from 'svelte/store';
import HighlightedText from './HighlightedText.svelte';
let current_name: string = '';
let current_valid: boolean = false;
let current_name: string = $state('');
let current_valid: boolean = $state(false);
let popup_content: PopupContent = $state({
open: false,
@@ -40,6 +53,8 @@
}
const show_new_folder_popup = () => {
current_name = '';
current_valid = false;
popup_content = {
open: true,
snippet: new_folder_popup,
@@ -48,35 +63,120 @@
closable: true
};
};
const delete_folder_element_popup = () => {
popup_content = {
open: true,
snippet: delete_request_popup,
title: `${$selected_file_ids.length} ${$selected_file_ids.length === 1 ? 'Objekt' : 'Objekte'} wirklich löschen?`,
title_icon: Trash2,
closable: true
};
};
</script>
{#snippet new_folder_popup()}
<div>
<TextInput
current_value={current_name}
{current_valid}
title="Ordnername"
is_valid_function={(input: string) => {
if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen'];
const trimmed_input = input.trim();
if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge'];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if (
get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).some(
(e) => e.name === trimmed_input
)
{#if get_display_ids_where_path_does_not_exist($current_file_path, $selected_display_ids, $all_files).length > 0}
<span class="leading-relaxed"
>Der aktuelle Pfad <HighlightedText
>{$current_file_path.slice(0, $current_file_path.length - 1)}</HighlightedText
> existiert nicht auf {get_display_ids_where_path_does_not_exist(
$current_file_path,
$selected_display_ids,
$all_files
).length === 1
? 'dem Bildschirm'
: 'den Bildschirmen'}
{#each get_display_ids_where_path_does_not_exist($current_file_path, $selected_display_ids, $all_files) as display_id, i}
{#if i !== 0}
,
{/if}
<HighlightedText>{get_display_by_id(display_id, $displays)?.name}</HighlightedText>
{/each}. Mit der Erstellung dieses Ordners wird der Pfad automatisch mit leeren Ordnern bis
zum aktuellen Pfad aufgefüllt.
</span>
{/if}
<TextInput
focused_on_start={true}
bind:current_value={current_name}
bind:current_valid
title="Ordnername"
is_valid_function={(input: string) => {
if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen'];
const trimmed_input = input.trim();
if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge'];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if (
get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).some(
(e) => e.name === trimmed_input
)
return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
)
return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
/>
<div class="flex flex-row justify-end gap-2">
<Button
className="px-4 font-bold"
click_function={async () => {
for (const display_id of $selected_display_ids) {
const display = get_display_by_id(display_id, $displays);
if (!display) continue;
const path_data = get_longest_existing_path_and_needed_parts(
$current_file_path,
display_id,
$all_files
);
await create_folders(display.ip, path_data.existing, [...path_data.needed, current_name]);
}
await update_current_folder_on_selected_displays();
popup_close_function();
}}
/>
disabled={!current_valid}>Neuen Ordner erstellen</Button
>
</div>
{/snippet}
{#snippet delete_request_popup()}
<div class="flex flex-col gap-1 h-full min-h-0 grow-0">
<span class="text-stone-400 px-1"
>{`${$selected_file_ids.length === 1 ? 'Folgendes Objekt' : `Folgende ${$selected_file_ids.length} Objekte`} löschen? (Wiederherstellung nicht möglich)`}</span
>
<div class="flex flex-col gap-2 overflow-auto h-full min-h-0 grow-0">
{#each $selected_file_ids
.map((file_id) => get_file_from_id(file_id, $all_files, $current_file_path))
.filter((element) => element !== null) as file}
<FolderElementObject {file} not_interactable />
{/each}
</div>
</div>
<div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={popup_close_function}>Fertig</Button>
<Button className="px-4 font-bold" click_function={popup_close_function}>Abbrechen</Button>
<Button
hover_bg="bg-red-400"
active_bg="bg-red-500"
className="px-4 flex text-red-400 hover:text-stone-100"
click_function={async () => {
await run_for_selected_files_on_selected_displays(
async (ip: string, file_names: string[]) => {
delete_files(ip, $current_file_path, file_names);
}
);
await update_current_folder_on_selected_displays();
selected_file_ids.update(() => {
return [];
});
popup_close_function();
}}>Löschen</Button
>
</div>
{/snippet}
{#snippet clipboard_hover_snippet()}
<div></div>
{/snippet}
<div class="bg-stone-800 h-full rounded-2xl grid grid-rows-[2.5rem_1fr] min-h-0">
<div class="bg-stone-700 flex justify-between w-full p-1 rounded-t-2xl min-w-0 gap-2">
<span class="text-xl font-bold pl-2 content-center truncate min-w-0">
@@ -110,7 +210,7 @@
<div class="flex flex-col gap-2 p-2 overflow-hidden relative rounded-b-2xl">
<div class="flex flex-col gap-2 p-2 bg-stone-750 rounded-xl">
<PathBar />
<div class="flex flex-row justify-between gap-6 overflow-x-auto">
<div class="flex flex-row justify-between gap-6">
<div class="flex flex-row gap-2 shrink-0">
<Button
title="Neuen Ordner erstellen (Neuen Ordner mit ausgewählten Objekten erstellen)"
@@ -152,13 +252,14 @@
hover_bg="bg-red-400"
active_bg="bg-red-500"
className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Trash2 /></Button
disabled={$selected_file_ids.length === 0}
click_function={delete_folder_element_popup}><Trash2 /></Button
>
</div>
</div>
</div>
<div class="min-h-0 h-full overflow-y-auto bg-stone-750 rounded-xl">
<div class="flex flex-col gap-2 p-2 min-h-0">
<div class="flex flex-col gap-2 p-2 min-h-0 max-w-full">
{#if $selected_display_ids.length === 0}
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center">
Es wurden keine Bildschirme ausgewählt.
@@ -46,7 +46,10 @@
import { onDestroy, onMount } from 'svelte';
import { liveQuery } from 'dexie';
let { file } = $props<{ file: FolderElement }>();
let { file, not_interactable = false } = $props<{
file: FolderElement;
not_interactable?: boolean;
}>();
let thumbnail_url: string | null = $state(null);
// Update thumbnail_url automatically if data is available
@@ -88,18 +91,21 @@
}
function get_grayed_out_text_color_strings(is_selected: boolean): string {
if (not_interactable) return 'text-stone-400';
const color = is_selected ? 'text-stone-600' : 'text-stone-400';
const factor = is_selected ? -1 : 1;
return `${color} group-hover:${get_shifted_color(color, factor * 100)} group-active:${get_shifted_color(color, factor * 150)}`;
}
function get_grayed_out_border_color_strings(is_selected: boolean): string {
if (not_interactable) return 'border-stone-550';
const color = is_selected ? 'border-stone-450' : 'border-stone-550';
const factor = is_selected ? 1 : 1;
return `${color} group-hover:${get_shifted_color(color, factor * 100)} group-active:${get_shifted_color(color, factor * 150)}`;
}
function onclick(e: Event) {
if (not_interactable) return;
select(selected_file_ids, file.id);
e.stopPropagation();
}
@@ -115,51 +121,53 @@
</script>
<div class="flex flex-row h-{$current_height.file} w-full">
<div class="h-{$current_height.file} aspect-square max-w-15 flex">
<Button
disabled={!is_folder && get_file_type(file) === null}
title={!is_folder && get_file_type(file) === null ? 'Dateityp nicht unterstützt' : ''}
className="flex rounded-l-lg rounded-r-none {is_folder
? 'text-stone-450'
: 'text-stone-800'} w-full"
div_class="w-full"
bg={get_selectable_color_classes(
!is_folder && get_file_type(file) !== null,
{
bg: true
},
-50
)}
hover_bg={get_selectable_color_classes(
!is_folder,
{
bg: true
},
50
)}
active_bg={get_selectable_color_classes(
!is_folder,
{
bg: true
},
100
)}
click_function={(e) => {
open();
e.stopPropagation();
}}
>
{#if is_folder}
<ArrowRight class="size-full" strokeWidth="3" />
{:else if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[0].length !== 0}
<RefreshPlay className="size-full" />
{:else if get_file_type(file) !== null}
<Play class="size-full" strokeWidth="3" />
{:else}
<Ban class="size-full" strokeWidth="3" />
{/if}
</Button>
</div>
{#if !not_interactable}
<div class="h-{$current_height.file} aspect-square max-w-15 flex">
<Button
disabled={!is_folder && get_file_type(file) === null}
title={!is_folder && get_file_type(file) === null ? 'Dateityp nicht unterstützt' : ''}
className="flex rounded-l-lg rounded-r-none {is_folder
? 'text-stone-450'
: 'text-stone-800'} w-full"
div_class="w-full"
bg={get_selectable_color_classes(
!is_folder && get_file_type(file) !== null,
{
bg: true
},
-50
)}
hover_bg={get_selectable_color_classes(
!is_folder,
{
bg: true
},
50
)}
active_bg={get_selectable_color_classes(
!is_folder,
{
bg: true
},
100
)}
click_function={(e) => {
open();
e.stopPropagation();
}}
>
{#if is_folder}
<ArrowRight class="size-full" strokeWidth="3" />
{:else if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[0].length !== 0}
<RefreshPlay className="size-full" />
{:else if get_file_type(file) !== null}
<Play class="size-full" strokeWidth="3" />
{:else}
<Ban class="size-full" strokeWidth="3" />
{/if}
</Button>
</div>
{/if}
<div
role="button"
tabindex="0"
@@ -167,12 +175,17 @@
if (e.key === 'Enter' || e.key === ' ') onclick(e);
}}
{onclick}
class="{get_selectable_color_classes(is_selected(file.id, $selected_file_ids), {
bg: true,
hover: true,
active: true,
text: true
})} rounded-r-lg transition-colors duration-200 gap-4 flex flex-row justify-between cursor-pointer group w-full min-w-0"
class="{get_selectable_color_classes(
!not_interactable && is_selected(file.id, $selected_file_ids),
{
bg: true,
hover: !not_interactable,
active: !not_interactable,
text: true
}
)} {not_interactable
? 'rounded-lg'
: 'rounded-r-lg cursor-pointer'} transition-colors duration-200 gap-4 flex flex-row justify-between group w-full min-w-0"
>
<div class="flex flex-row gap-2 min-w-0 w-full">
<div class="aspect-square rounded-md flex justify-center items-center">
@@ -203,7 +216,7 @@
is_selected(file.id, $selected_file_ids)
)} duration-200 transition-colors"
>
{#if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[1].length !== 0}
<!-- {#if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[1].length !== 0}
<Button
className="h-8 aspect-square transition-colors duration-200 !p-1.5 text-stone-100"
bg="bg-red-500"
@@ -229,7 +242,7 @@
>
<RefreshCcwDot class="size-full" />
</Button>
{/if}
{/if} -->
<div
class="w-14 content-center text-center select-none text-xs whitespace-nowrap"
title={get_created_string(file.date_created, true)}
+50 -34
View File
@@ -36,7 +36,7 @@ export async function show_html(ip: string, html: string) {
await request_display(ip, '/showHTML', options);
}
export async function get_file_data(ip: string, path: string): Promise<FolderElement[]> {
export async function get_file_data(ip: string, path: string): Promise<FolderElement[] | null> {
interface FileInfo {
name: string;
type: string;
@@ -44,25 +44,20 @@ export async function get_file_data(ip: string, path: string): Promise<FolderEle
created: string;
}
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do
typ=$(file -b --mime-type -- "$f")
size=$(stat -c '%s' -- "$f")
created=$(stat -c '%w' -- "$f")
[ "$created" = "-" ] && created=$(stat -c '%y' -- "$f")
jq -n --arg name "$f" --arg type "$typ" --arg size "$size" --arg created "$created" \
'{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n'
echo
done
` })
};
const raw_response = await request_display(ip, '/shellCommand', options);
if (!raw_response.ok || !raw_response.json) return [];
const raw_response = await run_shell_command(ip, `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do
typ=$(file -b --mime-type -- "$f")
size=$(stat -c '%s' -- "$f")
created=$(stat -c '%w' -- "$f")
[ "$created" = "-" ] && created=$(stat -c '%y' -- "$f")
jq -n --arg name "$f" --arg type "$typ" --arg size "$size" --arg created "$created" \
'{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n'
echo
done
`);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (is_cd_directory_error(ip, json_response)) return [];
if (json_response.exitCode === 0 && json_response.stdout.trim() === '') return [];
if (is_cd_directory_error(ip, json_response)) return null;
const response: FileInfo[] = json_response.stdout.trim()
.split("\n")
@@ -94,14 +89,7 @@ done
}
export async function get_file_tree_data(ip: string, path: string): Promise<TreeElement[] | null> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: `cd ".${path}" && tree -Js`
})
};
const raw_response = await request_display(ip, '/shellCommand', options);
const raw_response = await run_shell_command(ip, `cd ".${path}" && tree -Js`);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
@@ -110,6 +98,25 @@ export async function get_file_tree_data(ip: string, path: string): Promise<Tree
return (JSON.parse(json_response.stdout.trim()) as [TreeElement, any])[0].contents || null;
}
export async function create_folders(ip: string, path: string, folder_names: string[]): Promise<void> {
let command = `cd ".${path}"`;
for (const part of folder_names) {
command += `&& mkdir "${part}" && cd "${part}/"`;
}
await run_shell_command(ip, command);
}
export async function delete_files(ip: string, current_path: string, file_names: string[]) {
let del_string: string = '';
for (const file_name of file_names) {
del_string += `&& rm -r "${file_name}"`;
}
await run_shell_command(ip, `cd ".${current_path}" ${del_string}`);
}
export async function show_blackscreen(ip: string): Promise<void> {
const options = {
@@ -122,19 +129,17 @@ export async function show_blackscreen(ip: string): Promise<void> {
await request_display(ip, '/showHTML', options);
}
export async function ping_ip(ip: string): Promise<DisplayStatus> {
const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' });
if (!raw_response.ok || !raw_response.json) return null;
return raw_response.json.status ? to_display_status(raw_response.json.status) : null;
}
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' }, [415]);
if (!raw_response.ok || !raw_response.blob) return null
return raw_response.blob;
}
export async function ping_ip(ip: string): Promise<DisplayStatus> {
const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' });
if (!raw_response.ok || !raw_response.json) return null;
return raw_response.json.status ? to_display_status(raw_response.json.status) : null;
}
@@ -203,4 +208,15 @@ function is_cd_directory_error(ip: string, shell_response: ShellCommandResponse)
}
if (shell_response.stdout.trim() === '') return true;
return false;
}
async function run_shell_command(ip: string, command: string): Promise<RequestResponse> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: command
})
};
return await request_display(ip, '/shellCommand', options);
}
+70 -3
View File
@@ -1,7 +1,7 @@
import { get, writable, type Writable } from "svelte/store";
import type { Display, FolderElement, TreeElement } from "../types";
import { displays } from "./displays";
import { selected_file_ids } from "./select";
import { displays, get_display_by_id } from "./displays";
import { selected_display_ids, selected_file_ids } from "./select";
import { get_file_data, get_file_tree_data } from "../api_handler";
import { notifications } from "./notification";
import { CirclePoundSterling } from "lucide-svelte";
@@ -45,8 +45,17 @@ export async function change_file_path(new_path: string) {
}
}
export async function update_current_folder_on_selected_displays() {
const current_path = get(current_file_path);
for (const display_id of get(selected_display_ids)) {
const display = get_display_by_id(display_id, get(displays));
if (!display) continue;
update_folder_elements_recursively(display, current_path);
}
}
export function get_display_ids_where_file_is_missing(path: string, file: FolderElement, selected_display_ids: string[], all_files: Record<string, Record<string, FolderElement[]>>): string[][] {
if (!all_files.hasOwnProperty(path)) return [];
if (!all_files.hasOwnProperty(path)) return [selected_display_ids, []];
const missing: string[] = [];
const colliding: string[] = [];
Display:
@@ -68,6 +77,17 @@ export function get_display_ids_where_file_is_missing(path: string, file: Folder
return [missing, colliding];
}
export function get_display_ids_where_path_does_not_exist(path: string, selected_display_ids: string[], all_files: Record<string, Record<string, FolderElement[]>>): string[] {
if (!all_files.hasOwnProperty(path)) return selected_display_ids;
const out: string[] = [];
for (const selected_display_id of selected_display_ids) {
if (!all_files[path].hasOwnProperty(selected_display_id)) {
out.push(selected_display_id);
}
}
return out;
}
async function get_changed_directory_paths(display: Display, file_path: string): Promise<string[] | null> {
const current_folder = await get_file_tree_data(display.ip, file_path);
if (current_folder === null) return [file_path];
@@ -103,6 +123,7 @@ function get_recursive_changed_directory_paths(display: Display, current_file_pa
export async function update_folder_elements_recursively(display: Display, file_path: string = '/'): Promise<number> {
const new_folder_elements = await get_file_data(display.ip, file_path);
if (new_folder_elements === null) return 0;
all_files.update((files: Record<string, Record<string, FolderElement[]>>) => {
if (!files.hasOwnProperty(file_path)) {
files[file_path] = {};
@@ -223,8 +244,54 @@ function sort_files(files: FolderElement[]) {
if (nameCompare !== 0) return nameCompare;
// Wenn name gleich, absteigend nach date_created
if (!b.date_created || !a.date_created) return -1;
return b.date_created.getTime() - a.date_created.getTime();
});
return files;
}
export function get_file_from_id(file_id: string, all_files: Record<string, Record<string, FolderElement[]>>, current_file_path: string): FolderElement | null {
const current_path_elements: Record<string, FolderElement[]> | undefined = all_files[current_file_path];
if (!current_path_elements) return 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> {
const files = get(all_files);
const file_path = get(current_file_path);
const folder_element_hashs: string[] = get(selected_file_ids)
.map((file_id) => get_file_from_id(file_id, files, file_path))
.filter((element) => element !== null)
.map((folder_element) => folder_element.hash)
.filter((hash) => hash !== null);
for (const display_id of get(selected_display_ids)) {
if (!files[file_path].hasOwnProperty(display_id)) continue;
const files_on_display = files[file_path][display_id];
const selected_file_names_on_display: string[] = files_on_display.filter((e) => e.hash && folder_element_hashs.includes(e.hash)).map((folder_element) => folder_element.name);
if (selected_file_names_on_display.length === 0) continue;
const display = get_display_by_id(display_id, get(displays));
if (!display) continue
await action(display.ip, selected_file_names_on_display);
}
}
export function get_longest_existing_path_and_needed_parts(path: string, display_id: string, all_files: Record<string, Record<string, FolderElement[]>>): { existing: string; needed: string[] } {
const path_parts = path.slice(0, path.length - 1).split('/');
for (let i = path_parts.length; i > 1; i--) {
const current_path = [...path_parts].splice(0, i).join('/') + '/';;
if (all_files.hasOwnProperty(current_path)) {
if (all_files[current_path].hasOwnProperty(display_id)) {
return { existing: current_path, needed: [...path_parts].splice(i) };
}
}
}
return { existing: '/', needed: path_parts };
}