mirror of
https://codeberg.org/PLG-Development/PLG-MuDiCS
synced 2026-07-06 00:47:09 +00:00
refactor(frontend): split true components from modules
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Notification from '$lib/components/Notification.svelte';
|
||||
import Notification from './Notification.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Monitor, Plus, Radio, Settings, Trash2, X } from 'lucide-svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import FileView from '$lib/components/FileView.svelte';
|
||||
import ControlView from '$lib/components/ControlView.svelte';
|
||||
import DisplayView from '$lib/components/DisplayView.svelte';
|
||||
import FileView from './FileView.svelte';
|
||||
import ControlView from './ControlView.svelte';
|
||||
import DisplayView from './DisplayView.svelte';
|
||||
import PopUp from '$lib/components/PopUp.svelte';
|
||||
import { type PopupContent } from '$lib/ts/types';
|
||||
import TextInput from '$lib/components/TextInput.svelte';
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ArrowBigLeft,
|
||||
ArrowBigRight,
|
||||
ChevronDown,
|
||||
Keyboard,
|
||||
Power,
|
||||
PowerOff,
|
||||
Presentation,
|
||||
SquareTerminal,
|
||||
TextAlignStart,
|
||||
TrafficCone
|
||||
} from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import PopUp from '$lib/components/PopUp.svelte';
|
||||
import type { PopupContent } from '$lib/ts/types';
|
||||
import KeyInput from './KeyInput.svelte';
|
||||
import { send_keyboard_input, show_blackscreen } from '$lib/ts/api_handler';
|
||||
import { run_on_all_selected_displays } from '$lib/ts/stores/displays';
|
||||
import { selected_display_ids } from '$lib/ts/stores/select';
|
||||
import TipTapInput from './TipTapInput.svelte';
|
||||
|
||||
let popup_content: PopupContent = $state({
|
||||
open: false,
|
||||
snippet: null,
|
||||
title: '',
|
||||
closable: true
|
||||
});
|
||||
|
||||
function popup_close_function() {
|
||||
popup_content.open = false;
|
||||
}
|
||||
|
||||
const show_send_keys_popup = () => {
|
||||
popup_content = {
|
||||
open: true,
|
||||
snippet: send_keys_popup,
|
||||
title: 'Tastatur-Eingaben durchgeben',
|
||||
title_icon: Keyboard,
|
||||
closable: true
|
||||
};
|
||||
};
|
||||
|
||||
const show_text_popup = () => {
|
||||
popup_content = {
|
||||
open: true,
|
||||
snippet: text_popup,
|
||||
title: 'Text anzeigen',
|
||||
title_icon: TextAlignStart,
|
||||
closable: true,
|
||||
window_class: 'size-full'
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet send_keys_popup()}
|
||||
<div class="overflow-hidden flex flex-col gap-2">
|
||||
<div>
|
||||
<KeyInput />
|
||||
</div>
|
||||
<div class="flex flex-row justify-end gap-2">
|
||||
<Button className="px-4 font-bold" click_function={popup_close_function}>Fertig</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet text_popup()}
|
||||
<TipTapInput />
|
||||
{/snippet}
|
||||
|
||||
<div class="grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl min-w-0">
|
||||
<div class="text-xl font-bold pl-3 content-center bg-stone-700 rounded-t-2xl truncate min-w-0">
|
||||
Bildschirme steuern
|
||||
</div>
|
||||
<div class="relative flex flex-col gap-2 p-2 overflow-auto">
|
||||
<div class="flex flex-row justify-between gap-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2 w-75 justify-normal">
|
||||
<Button
|
||||
title="Vorherige Folie (Pfeil nach Links)"
|
||||
className="px-9"
|
||||
disabled={$selected_display_ids.length === 0}
|
||||
click_function={async () => {
|
||||
await run_on_all_selected_displays(send_keyboard_input, true, 'VK_LEFT');
|
||||
}}><ArrowBigLeft /></Button
|
||||
>
|
||||
<Button
|
||||
title="Nächste Folie (Pfeil nach Rechts)"
|
||||
className="px-9"
|
||||
disabled={$selected_display_ids.length === 0}
|
||||
click_function={async () => {
|
||||
await run_on_all_selected_displays(send_keyboard_input, true, 'VK_RIGHT');
|
||||
}}><ArrowBigRight /></Button
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
className="px-3 flex gap-3 w-75 justify-normal"
|
||||
disabled={$selected_display_ids.length === 0}
|
||||
click_function={show_text_popup}><TextAlignStart /> Text anzeigen</Button
|
||||
>
|
||||
<Button
|
||||
className="px-3 flex gap-3 w-75 justify-normal"
|
||||
disabled={$selected_display_ids.length === 0}
|
||||
click_function={async () => {
|
||||
await run_on_all_selected_displays(show_blackscreen, true);
|
||||
}}><Presentation />Blackout</Button
|
||||
>
|
||||
<div class="flex flex-row justify-normal">
|
||||
<Button
|
||||
className="rounded-r-none pl-3 flex gap-3 grow w-65 justify-normal"
|
||||
disabled={$selected_display_ids.length === 0}
|
||||
><TrafficCone /> Fallback-Bild anzeigen</Button
|
||||
>
|
||||
<Button className="rounded-l-none flex grow-0 w-10"><ChevronDown /></Button>
|
||||
</div>
|
||||
<Button
|
||||
className="px-3 flex gap-3 w-75 justify-normal"
|
||||
disabled={$selected_display_ids.length === 0}
|
||||
click_function={show_send_keys_popup}><Keyboard /> Tastatur-Eingaben durchgeben</Button
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
|
||||
disabled={$selected_display_ids.length === 0}><Power /> PC hochfahren</Button
|
||||
>
|
||||
<Button
|
||||
className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
|
||||
disabled={$selected_display_ids.length === 0}><PowerOff /> PC herunterfahren</Button
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
|
||||
disabled={$selected_display_ids.length === 0}
|
||||
><SquareTerminal /> Shell-Befehl ausführen</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<PopUp
|
||||
content={popup_content}
|
||||
close_function={popup_close_function}
|
||||
className="rounded-b-2xl"
|
||||
snippet_container_class="size-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { dragHandleZone, TRIGGERS } from 'svelte-dnd-action';
|
||||
import {
|
||||
dnd_flip_duration_ms,
|
||||
get_selectable_color_classes,
|
||||
is_display_drag,
|
||||
is_group_drag
|
||||
} from '$lib/ts/stores/ui_behavior';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { flip } from 'svelte/animate';
|
||||
import DisplayObject from './DisplayObject.svelte';
|
||||
import {
|
||||
all_displays_of_group_selected,
|
||||
get_display_ids_in_group,
|
||||
select_all_of_group,
|
||||
set_new_display_order
|
||||
} from '$lib/ts/stores/displays';
|
||||
import DNDGrip from '$lib/components/DNDGrip.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { MenuOption } from '$lib/ts/types';
|
||||
import { selected_display_ids } from '$lib/ts/stores/select';
|
||||
import { liveQuery } from 'dexie';
|
||||
|
||||
let {
|
||||
display_group_id,
|
||||
get_display_menu_options,
|
||||
close_pinned_display
|
||||
}: {
|
||||
display_group_id: string;
|
||||
get_display_menu_options: (display_id: string) => MenuOption[];
|
||||
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);
|
||||
|
||||
async function select_all_of_this_group() {
|
||||
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);
|
||||
}
|
||||
|
||||
async function handle_consider(e: CustomEvent) {
|
||||
const { items, info } = e.detail;
|
||||
|
||||
if (items.length !== 1 && info.trigger === TRIGGERS.DRAG_STARTED) {
|
||||
$is_display_drag = true;
|
||||
// add_empty_display_group();
|
||||
}
|
||||
await set_new_display_order(items);
|
||||
}
|
||||
|
||||
async function handle_finalize(e: CustomEvent) {
|
||||
$is_display_drag = false;
|
||||
await set_new_display_order(e.detail.items);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
transition:fade={{ duration: 100 }}
|
||||
class="{get_selectable_color_classes(
|
||||
$all_selected || false,
|
||||
{
|
||||
bg: true,
|
||||
hover: hovering_selectable,
|
||||
active: hovering_selectable,
|
||||
text: true
|
||||
},
|
||||
-150,
|
||||
-50
|
||||
)} transition-colors duration-200 rounded-2xl flex flex-row cursor-pointer"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col min-w-0 pl-2 py-2 gap-2 w-full"
|
||||
use:dragHandleZone={{
|
||||
items: $display_ids_in_group || [],
|
||||
type: 'item',
|
||||
flipDurationMs: dnd_flip_duration_ms,
|
||||
dropTargetStyle: { outline: 'none' }
|
||||
}}
|
||||
onconsider={handle_consider}
|
||||
onfinalize={handle_finalize}
|
||||
>
|
||||
{#each $display_ids_in_group || [] as display (display.id)}
|
||||
<!-- Each Group -->
|
||||
<section
|
||||
animate:flip={{ duration: $is_group_drag ? 0 : dnd_flip_duration_ms, easing: cubicOut }}
|
||||
class="outline-none"
|
||||
role="figure"
|
||||
>
|
||||
<DisplayObject {display} {get_display_menu_options} {close_pinned_display} />
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#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">
|
||||
Hier in leere neue Gruppe ablegen
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-center px-2"
|
||||
onclick={select_all_of_this_group}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={async (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') await select_all_of_this_group();
|
||||
}}
|
||||
onmouseenter={() => (hovering_selectable = true)}
|
||||
onmouseleave={() => (hovering_selectable = false)}
|
||||
>
|
||||
<DNDGrip
|
||||
bg={get_selectable_color_classes(
|
||||
$all_selected || false,
|
||||
{
|
||||
bg: true
|
||||
},
|
||||
-150
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import {
|
||||
current_height,
|
||||
get_selectable_color_classes,
|
||||
pinned_display_id
|
||||
} from '$lib/ts/stores/ui_behavior';
|
||||
import DNDGrip from '$lib/components/DNDGrip.svelte';
|
||||
import { Menu, Pin, PinOff, VideoOff } from 'lucide-svelte';
|
||||
import OnlineState from './OnlineState.svelte';
|
||||
import type { Display, MenuOption } from '$lib/ts/types';
|
||||
import { is_selected, select, selected_display_ids } from '$lib/ts/stores/select';
|
||||
import { screenshot_loop } from '$lib/ts/stores/displays';
|
||||
import { change_file_path, current_file_path } from '$lib/ts/stores/files';
|
||||
|
||||
let {
|
||||
display,
|
||||
get_display_menu_options,
|
||||
close_pinned_display
|
||||
}: {
|
||||
display: Display;
|
||||
get_display_menu_options: (display_id: string) => MenuOption[];
|
||||
close_pinned_display: () => void;
|
||||
} = $props();
|
||||
|
||||
let hovering_unselectable = $state(false);
|
||||
|
||||
async function onclick(e: Event) {
|
||||
e.stopPropagation();
|
||||
select(selected_display_ids, display.id, 'toggle');
|
||||
|
||||
// force file view update
|
||||
await change_file_path($current_file_path);
|
||||
}
|
||||
|
||||
async function on_preview_click(e: MouseEvent) {
|
||||
if ($pinned_display_id === display.id) {
|
||||
close_pinned_display();
|
||||
} else {
|
||||
$pinned_display_id = display.id;
|
||||
}
|
||||
await screenshot_loop(display.id);
|
||||
e.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="display"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onclick(e);
|
||||
}}
|
||||
{onclick}
|
||||
class="p-1 {get_selectable_color_classes(is_selected(display.id, $selected_display_ids), {
|
||||
bg: true,
|
||||
hover: true,
|
||||
active: !hovering_unselectable,
|
||||
text: true
|
||||
})} rounded-xl flex flex-row justify-between h-{$current_height.display} transition-colors duration-100 gap-2 cursor-pointer w-full text-stone-200"
|
||||
>
|
||||
<div class="flex flex-row gap-4 min-w-0 flex-1">
|
||||
<!-- Left Preview Screen -->
|
||||
<button
|
||||
class="group relative aspect-video bg-stone-800 h-full rounded-lg overflow-hidden cursor-pointer text-stone-200 transition-colors duration-200"
|
||||
onmouseenter={() => (hovering_unselectable = true)}
|
||||
onmouseleave={() => (hovering_unselectable = false)}
|
||||
onclick={on_preview_click}
|
||||
>
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
{#if $pinned_display_id === display.id}
|
||||
<div class="size-[50%]">
|
||||
<Pin class="size-full" />
|
||||
</div>
|
||||
{:else if display.preview.url}
|
||||
<img src={display.preview.url} alt="preview" class="w-full object-cover bg-black" />
|
||||
{:else}
|
||||
<!-- No Signal -->
|
||||
<div class="size-full bg-black flex justify-center items-center">
|
||||
<VideoOff class="size-[30%]" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hover Effect -->
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 {$pinned_display_id === display.id
|
||||
? 'bg-stone-700'
|
||||
: 'bg-stone-700/70'} opacity-0 transition-opacity duration-200 flex items-center justify-center group-hover:opacity-100"
|
||||
>
|
||||
{#if $pinned_display_id === display.id}
|
||||
<PinOff class="size-[50%]" aria-hidden="true" />
|
||||
{:else}
|
||||
<Pin class="size-[50%]" aria-hidden="true" />
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
<!-- Middle Text Block -->
|
||||
<div
|
||||
class="h-full flex flex-col justify-center gap-1 select-none
|
||||
min-w-0 basis-0 flex-1"
|
||||
>
|
||||
<div class="text-xl font-bold truncate w-full" title={display.name}>
|
||||
{display.name}
|
||||
</div>
|
||||
|
||||
<OnlineState
|
||||
selected={is_selected(display.id, $selected_display_ids)}
|
||||
status={display.status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right Controls -->
|
||||
<div class="flex flex-row h-full items-center gap-2 pr-3">
|
||||
<DNDGrip
|
||||
bg={get_selectable_color_classes(is_selected(display.id, $selected_display_ids), {
|
||||
bg: true
|
||||
})}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="figure"
|
||||
onmouseenter={() => (hovering_unselectable = true)}
|
||||
onmouseleave={() => (hovering_unselectable = false)}
|
||||
>
|
||||
<Button
|
||||
bg="bg-transparent"
|
||||
hover_bg={get_selectable_color_classes(is_selected(display.id, $selected_display_ids), {
|
||||
bg: true
|
||||
})}
|
||||
active_bg={get_selectable_color_classes(is_selected(display.id, $selected_display_ids), {
|
||||
bg: true
|
||||
})}
|
||||
click_function={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
menu_options={get_display_menu_options(display.id)}
|
||||
>
|
||||
<Menu />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import {
|
||||
all_displays_of_group_selected,
|
||||
get_display_by_id,
|
||||
get_display_groups,
|
||||
select_all_of_group,
|
||||
set_new_display_group_order
|
||||
} from '$lib/ts/stores/displays';
|
||||
import {
|
||||
change_height,
|
||||
current_height,
|
||||
dnd_flip_duration_ms,
|
||||
get_selectable_color_classes,
|
||||
is_display_drag,
|
||||
is_group_drag,
|
||||
next_height_step_size,
|
||||
pinned_display_id
|
||||
} from '$lib/ts/stores/ui_behavior';
|
||||
import { type Display, type DisplayGroup, type MenuOption } from '$lib/ts/types';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import OnlineState from './OnlineState.svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { Menu, Pencil, PinOff, Settings, Trash2, VideoOff, ZoomIn, ZoomOut } from 'lucide-svelte';
|
||||
import { selected_display_ids } from '$lib/ts/stores/select';
|
||||
import { dragHandleZone } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import DisplayGroupObject from './DisplayGroupObject.svelte';
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes';
|
||||
import HighlightedText from '$lib/components/HighlightedText.svelte';
|
||||
import { liveQuery, type Observable } from 'dexie';
|
||||
|
||||
let {
|
||||
handle_display_deletion,
|
||||
handle_display_editing
|
||||
}: {
|
||||
handle_display_deletion: (display_id: string) => void;
|
||||
handle_display_editing: (display_id: string) => void;
|
||||
} = $props();
|
||||
|
||||
let displays_scroll_box: HTMLElement;
|
||||
let pinned_display: Observable<Display | null> = liveQuery(() =>
|
||||
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 pinned_pane_size: number = $state(last_pinned_pane_size);
|
||||
|
||||
function close_pinned_display() {
|
||||
last_pinned_pane_size = pinned_pane_size;
|
||||
pinned_pane_size = 0;
|
||||
}
|
||||
|
||||
function get_display_menu_options(display_id: string): MenuOption[] {
|
||||
return [
|
||||
{
|
||||
icon: Pencil,
|
||||
name: 'Bildschirm bearbeiten',
|
||||
on_select: () => {
|
||||
handle_display_editing(display_id);
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
name: 'Bildschirm löschen',
|
||||
class: 'text-red-400 hover:text-stone-200 hover:!bg-red-400 active:!bg-red-500',
|
||||
on_select: () => {
|
||||
handle_display_deletion(display_id);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async function select_all(
|
||||
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) {
|
||||
await select_all_of_group(display_group.id, new_value);
|
||||
}
|
||||
}
|
||||
|
||||
async function all_selected(
|
||||
current_displays: DisplayGroup[],
|
||||
current_selected_display_ids: string[]
|
||||
) {
|
||||
for (const display_group of current_displays) {
|
||||
if (!(await all_displays_of_group_selected(display_group.id, current_selected_display_ids))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handle_splitpane_resize(e: any) {
|
||||
if (e.detail[0].size === 0) {
|
||||
$pinned_display_id = null;
|
||||
pinned_pane_size = last_pinned_pane_size;
|
||||
}
|
||||
}
|
||||
|
||||
function on_wheel(e: WheelEvent) {
|
||||
if (!$is_group_drag && !$is_display_drag) return;
|
||||
if (!displays_scroll_box) return;
|
||||
|
||||
// apply custom scroll feature
|
||||
e.preventDefault();
|
||||
(displays_scroll_box as HTMLElement).scrollBy?.({
|
||||
top: e.deltaY,
|
||||
behavior: 'auto'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:wheel={on_wheel} />
|
||||
|
||||
<div class="h-[calc(100dvh-3rem-(6*var(--spacing)))] flex flex-col gap-2">
|
||||
<Splitpanes
|
||||
horizontal
|
||||
theme="mudics-stone-theme"
|
||||
on:resized={handle_splitpane_resize}
|
||||
dblClickSplitter={false}
|
||||
>
|
||||
{#if $pinned_display_id}
|
||||
<!-- Pinned Item -->
|
||||
<Pane maxSize={60} snapSize={20} bind:size={pinned_pane_size}>
|
||||
<div class="h-full" transition:fade>
|
||||
<div class="grid grid-rows-[2.5rem_auto] will-change-[height,opacity] h-full">
|
||||
<div
|
||||
class="bg-stone-700 rounded-t-2xl flex justify-between w-full p-1 min-w-0 basis-0 flex-1"
|
||||
>
|
||||
<span
|
||||
class="text-xl font-bold pl-2 content-center truncate min-w-0"
|
||||
title={$pinned_display?.name || '...'}
|
||||
>
|
||||
{$pinned_display?.name || '...'}
|
||||
</span>
|
||||
<div class="flex flex-row gap-1">
|
||||
<div class="flex flex-row items-center mr-1">
|
||||
<span class="text-stone-400"> Aktueller Status: </span>
|
||||
<OnlineState
|
||||
selected={false}
|
||||
status={$pinned_display?.status ?? null}
|
||||
className="flex items-center px-2"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="aspect-square !p-1"
|
||||
bg="bg-stone-600"
|
||||
click_function={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
menu_options={get_display_menu_options($pinned_display_id)}
|
||||
>
|
||||
<Menu />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
title="Bildschirm nicht mehr anpinnen"
|
||||
className="aspect-square !p-1"
|
||||
bg="bg-stone-600"
|
||||
click_function={close_pinned_display}
|
||||
>
|
||||
<PinOff />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-full bg-stone-800 rounded-b-2xl overflow-hidden flex justify-center items-center"
|
||||
>
|
||||
{#if $pinned_display?.preview.url}
|
||||
<img
|
||||
src={$pinned_display.preview.url}
|
||||
alt="preview"
|
||||
class="max-h-full max-w-full object-cover bg-black"
|
||||
/>
|
||||
{:else}
|
||||
<div class="size-full bg-black flex justify-center items-center">
|
||||
<VideoOff class="size-[20%]" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
{/if}
|
||||
|
||||
<Pane>
|
||||
<div
|
||||
class="min-h-0 h-full grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl overflow-hidden"
|
||||
>
|
||||
<!-- Normal Heading Left -->
|
||||
<div class="bg-stone-700 flex justify-between w-full p-1 gap-2 min-w-0">
|
||||
<span class="text-xl font-bold pl-2 content-center truncate min-w-0">
|
||||
Verbundene Bildschirme
|
||||
</span>
|
||||
<div class="flex flex-row gap-1">
|
||||
<button
|
||||
class="min-w-40 px-4 rounded-xl cursor-pointer duration-200 transition-colors {get_selectable_color_classes(
|
||||
$all_groups_selected || false,
|
||||
{
|
||||
bg: true,
|
||||
hover: true,
|
||||
active: true,
|
||||
text: true
|
||||
}
|
||||
)}"
|
||||
onclick={async () => await select_all($display_groups || [], $selected_display_ids)}
|
||||
>
|
||||
<span>{$all_groups_selected || false ? 'Alle abwählen' : 'Alle auswählen'}</span>
|
||||
</button>
|
||||
<div class="flex flex-row">
|
||||
<Button
|
||||
title="Bildschirme größer darstellen"
|
||||
className="aspect-square !p-1 rounded-r-none"
|
||||
bg="bg-stone-600"
|
||||
disabled={!Boolean(next_height_step_size('display', $current_height, 1))}
|
||||
click_function={() => {
|
||||
change_height('display', 1);
|
||||
}}
|
||||
>
|
||||
<ZoomIn />
|
||||
</Button>
|
||||
<Button
|
||||
title="Bildschirme kleiner darstellen"
|
||||
className="aspect-square !p-1 rounded-l-none"
|
||||
bg="bg-stone-600"
|
||||
disabled={!Boolean(next_height_step_size('display', $current_height, -1))}
|
||||
click_function={() => {
|
||||
change_height('display', -1);
|
||||
}}
|
||||
>
|
||||
<ZoomOut />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 overflow-y-auto" bind:this={displays_scroll_box}>
|
||||
<div
|
||||
class="min-h-full p-2 flex flex-col gap-4"
|
||||
use:dragHandleZone={{
|
||||
items: $display_groups || [],
|
||||
type: 'group',
|
||||
flipDurationMs: dnd_flip_duration_ms,
|
||||
dropFromOthersDisabled: true,
|
||||
dropTargetStyle: { outline: 'none' }
|
||||
}}
|
||||
onconsider={async (e: CustomEvent) => {
|
||||
$is_group_drag = true;
|
||||
await set_new_display_group_order(e.detail.items);
|
||||
}}
|
||||
onfinalize={async (e: CustomEvent) => {
|
||||
await set_new_display_group_order(e.detail.items);
|
||||
$is_group_drag = false;
|
||||
}}
|
||||
>
|
||||
{#if ($display_groups || []).length === 0}
|
||||
<div class="text-stone-500 px-10 py-6 leading-relaxed text-center">
|
||||
Es wurden noch keine Bildschirme hinzugefügt. Klicke oben rechts auf
|
||||
<HighlightedText fg="text-stone-450" className="!p-1"
|
||||
><Settings class="inline pb-1" /></HighlightedText
|
||||
>
|
||||
und
|
||||
<HighlightedText fg="text-stone-450">Neuen Bildschirm hinzufügen</HighlightedText>.
|
||||
</div>
|
||||
{:else}
|
||||
{#each $display_groups || [] as display_group (display_group.id)}
|
||||
<!-- Each Group -->
|
||||
<section
|
||||
out:scale={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
|
||||
animate:flip={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
|
||||
class="outline-none"
|
||||
>
|
||||
<DisplayGroupObject
|
||||
display_group_id={display_group.id}
|
||||
{get_display_menu_options}
|
||||
{close_pinned_display}
|
||||
/>
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
Executable
+366
@@ -0,0 +1,366 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ClipboardPaste,
|
||||
Download,
|
||||
FolderPlus,
|
||||
Pen,
|
||||
RefreshCcw,
|
||||
Scissors,
|
||||
Trash2,
|
||||
Upload,
|
||||
ZoomIn,
|
||||
ZoomOut
|
||||
} from 'lucide-svelte';
|
||||
import { change_height, current_height, next_height_step_size } from '$lib/ts/stores/ui_behavior';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import PathBar from './PathBar.svelte';
|
||||
import { selected_display_ids, selected_file_ids } from '$lib/ts/stores/select';
|
||||
import {
|
||||
current_file_path,
|
||||
get_current_folder_elements,
|
||||
get_file_by_id,
|
||||
run_for_selected_files_on_selected_displays,
|
||||
update_current_folder_on_selected_displays,
|
||||
get_displays_where_path_exists,
|
||||
create_folder_on_all_selected_displays
|
||||
} from '$lib/ts/stores/files';
|
||||
import { slide } from 'svelte/transition';
|
||||
import InodeElement from './InodeElement.svelte';
|
||||
import PopUp from '$lib/components/PopUp.svelte';
|
||||
import { get_file_primary_key, type Inode, type PopupContent } from '$lib/ts/types';
|
||||
import TextInput from '$lib/components/TextInput.svelte';
|
||||
import { is_valid_name } from '$lib/ts/utils';
|
||||
import { delete_files, rename_file } from '$lib/ts/api_handler';
|
||||
import HighlightedText from '$lib/components/HighlightedText.svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
|
||||
let current_name: string = $state('');
|
||||
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({
|
||||
open: false,
|
||||
snippet: null,
|
||||
title: '',
|
||||
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() {
|
||||
popup_content.open = false;
|
||||
}
|
||||
|
||||
async function create_new_folder() {
|
||||
await create_folder_on_all_selected_displays(
|
||||
current_name.trim(),
|
||||
$current_file_path,
|
||||
$selected_display_ids
|
||||
);
|
||||
await update_current_folder_on_selected_displays();
|
||||
popup_close_function();
|
||||
}
|
||||
|
||||
async function edit_file_name(new_file_name: string) {
|
||||
await run_for_selected_files_on_selected_displays(async (ip: string, file_names: string[]) => {
|
||||
if (file_names.length !== 1) {
|
||||
console.log('EEEERRRRROOOOOOR', file_names);
|
||||
return; // Error
|
||||
}
|
||||
await rename_file(ip, $current_file_path, file_names[0], new_file_name);
|
||||
});
|
||||
await update_current_folder_on_selected_displays();
|
||||
popup_close_function();
|
||||
}
|
||||
|
||||
const show_edit_file_popup = async () => {
|
||||
const file = await get_file_by_id($selected_file_ids[0]);
|
||||
if (!file) return;
|
||||
const is_folder = file.type === 'inode/directory';
|
||||
const extension = is_folder ? '' : '.' + file.name.split('.').at(-1) || '';
|
||||
current_name = file.name.slice(0, file.name.length - extension.length);
|
||||
current_valid = true;
|
||||
popup_content = {
|
||||
open: true,
|
||||
snippet: edit_file_name_popup,
|
||||
title: `${is_folder ? 'Ordner' : 'Datei'} umbenennen`,
|
||||
title_icon: FolderPlus,
|
||||
snippet_arg: extension,
|
||||
closable: true
|
||||
};
|
||||
};
|
||||
|
||||
const show_new_folder_popup = async () => {
|
||||
current_name = '';
|
||||
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 = {
|
||||
open: true,
|
||||
snippet: new_folder_popup,
|
||||
title: 'Neuen Ordner erstellen',
|
||||
title_icon: FolderPlus,
|
||||
closable: true
|
||||
};
|
||||
};
|
||||
|
||||
const delete_folder_element_popup = () => {
|
||||
popup_content = {
|
||||
open: true,
|
||||
snippet: delete_request_popup,
|
||||
title: `${$selected_file_ids.length} ${$selected_file_ids.length === 1 ? 'Objekt' : 'Objekte'} wirklich löschen?`,
|
||||
title_icon: Trash2,
|
||||
closable: true
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet new_folder_popup()}
|
||||
{#if display_names_where_path_does_not_exist.length > 0}
|
||||
<span class="leading-relaxed"
|
||||
>Der aktuelle Pfad <HighlightedText
|
||||
>{$current_file_path.slice(0, $current_file_path.length - 1)}</HighlightedText
|
||||
> existiert nicht auf {display_names_where_path_does_not_exist.length === 1
|
||||
? 'dem Bildschirm'
|
||||
: 'den Bildschirmen'}
|
||||
{#each display_names_where_path_does_not_exist as display_name, i}
|
||||
{#if i !== 0}
|
||||
,
|
||||
{/if}
|
||||
<HighlightedText>{display_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={async (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 (
|
||||
(await get_current_folder_elements($current_file_path, $selected_display_ids)).some(
|
||||
(e) => e.name === trimmed_input
|
||||
)
|
||||
)
|
||||
return [false, 'Name bereits verwendet'];
|
||||
return [true, 'Gültiger Name'];
|
||||
}}
|
||||
enter_mode="submit"
|
||||
enter_function={create_new_folder}
|
||||
/>
|
||||
<div class="flex flex-row justify-end gap-2">
|
||||
<Button className="px-4 font-bold" click_function={create_new_folder} disabled={!current_valid}
|
||||
>Neuen Ordner erstellen</Button
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet edit_file_name_popup(extension: string)}
|
||||
<TextInput
|
||||
focused_on_start={true}
|
||||
bind:current_value={current_name}
|
||||
bind:current_valid
|
||||
title="Neuer {extension === '' ? 'Ordner' : 'Datei'}name"
|
||||
is_valid_function={async (input: string) => {
|
||||
if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen'];
|
||||
const trimmed_input = input.trim() + extension;
|
||||
if (trimmed_input.length === 0 || trimmed_input.length > 50)
|
||||
return [false, 'Ungültige Länge'];
|
||||
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
|
||||
if (
|
||||
(await get_current_folder_elements($current_file_path, $selected_display_ids)).some(
|
||||
async (e) => e.name === trimmed_input && get_file_primary_key(e) !== $selected_file_ids[0]
|
||||
)
|
||||
)
|
||||
return [false, 'Name bereits verwendet'];
|
||||
return [true, 'Gültiger Name'];
|
||||
}}
|
||||
enter_mode="submit"
|
||||
enter_function={async () => await edit_file_name(current_name.trim() + extension)}
|
||||
{extension}
|
||||
/>
|
||||
<div class="flex flex-row justify-end gap-2">
|
||||
<Button
|
||||
className="px-4 font-bold"
|
||||
click_function={async () => await edit_file_name(current_name.trim() + extension)}
|
||||
disabled={!current_valid}>{extension === '' ? 'Ordner' : 'Datei'} umbenennen</Button
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet delete_request_popup()}
|
||||
<div class="flex flex-col gap-1 h-full min-h-0 grow-0">
|
||||
<span class="text-stone-400 px-1"
|
||||
>{`${$selected_file_ids.length === 1 ? 'Folgendes Objekt' : `Folgende ${$selected_file_ids.length} Objekte`} löschen? (Wiederherstellung nicht möglich)`}</span
|
||||
>
|
||||
<div class="flex flex-col gap-2 overflow-auto h-full min-h-0 grow-0">
|
||||
{#each $selected_files || [] as file}
|
||||
<InodeElement {file} not_interactable />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row justify-end gap-2">
|
||||
<Button className="px-4 font-bold" click_function={popup_close_function}>Abbrechen</Button>
|
||||
<Button
|
||||
hover_bg="bg-red-400"
|
||||
active_bg="bg-red-500"
|
||||
className="px-4 flex text-red-400 hover:text-stone-100"
|
||||
click_function={async () => {
|
||||
await run_for_selected_files_on_selected_displays(
|
||||
async (ip: string, file_names: string[]) => {
|
||||
await delete_files(ip, $current_file_path, file_names);
|
||||
}
|
||||
);
|
||||
await update_current_folder_on_selected_displays();
|
||||
popup_close_function();
|
||||
}}>Löschen</Button
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<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">
|
||||
Dateien anzeigen und verwalten
|
||||
</span>
|
||||
<div class="flex flex-ro">
|
||||
<Button
|
||||
title="Dateien größer darstellen"
|
||||
className="aspect-square !p-1 rounded-r-none"
|
||||
bg="bg-stone-600"
|
||||
disabled={!Boolean(next_height_step_size('file', $current_height, 1))}
|
||||
click_function={() => {
|
||||
change_height('file', 1);
|
||||
}}
|
||||
>
|
||||
<ZoomIn />
|
||||
</Button>
|
||||
<Button
|
||||
title="Dateien kleiner darstellen"
|
||||
className="aspect-square !p-1 rounded-l-none"
|
||||
bg="bg-stone-600"
|
||||
disabled={!Boolean(next_height_step_size('file', $current_height, -1))}
|
||||
click_function={() => {
|
||||
change_height('file', -1);
|
||||
}}
|
||||
>
|
||||
<ZoomOut />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-2 overflow-hidden relative rounded-b-2xl">
|
||||
<div class="flex flex-col gap-2 p-2 bg-stone-750 rounded-xl">
|
||||
<PathBar />
|
||||
<div class="flex flex-row justify-between gap-6">
|
||||
<div class="flex flex-row gap-2 shrink-0">
|
||||
<Button
|
||||
title="Neuen Ordner erstellen (Neuen Ordner mit ausgewählten Objekten erstellen)"
|
||||
className="px-3 flex"
|
||||
click_function={show_new_folder_popup}
|
||||
disabled={$selected_display_ids.length === 0}><FolderPlus /></Button
|
||||
>
|
||||
<div class="border border-stone-700 my-1"></div>
|
||||
<Button
|
||||
title="Datei(en) hochladen"
|
||||
className="px-3 flex"
|
||||
disabled={$selected_display_ids.length === 0}><Upload /></Button
|
||||
>
|
||||
<Button
|
||||
title="Ausgewählte Datei(en) herunterladen"
|
||||
className="px-3 flex"
|
||||
disabled={$selected_file_ids.length === 0}><Download /></Button
|
||||
>
|
||||
<div class="border border-stone-700 my-1"></div>
|
||||
<Button
|
||||
title="Aktuellen Ordner / Ausgewählte Datei(en) zwischen Bildschirmen synchronisieren"
|
||||
className="px-3 flex gap-3"
|
||||
disabled={$selected_display_ids.length === 0}
|
||||
><RefreshCcw />
|
||||
<span class="hidden 2xl:flex">Synchronisieren</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
title="Ausgewählte Datei(en) ausschneiden"
|
||||
className="px-3 flex"
|
||||
disabled={$selected_file_ids.length === 0}><Scissors /></Button
|
||||
>
|
||||
<Button
|
||||
title="Ausgewählte Datei(en) einfügen"
|
||||
className="px-3 flex"
|
||||
disabled={$selected_display_ids.length === 0}
|
||||
>
|
||||
<ClipboardPaste />
|
||||
</Button>
|
||||
<div class="border border-stone-700 my-1"></div>
|
||||
<Button
|
||||
title="Ausgewählte Datei umbenennen"
|
||||
className="px-3 flex"
|
||||
click_function={show_edit_file_popup}
|
||||
disabled={$selected_file_ids.length !== 1}><Pen /></Button
|
||||
>
|
||||
<Button
|
||||
title="Ausgewählte Datei(en) löschen"
|
||||
hover_bg="bg-red-400"
|
||||
active_bg="bg-red-500"
|
||||
className="px-3 flex"
|
||||
disabled={$selected_file_ids.length === 0}
|
||||
click_function={delete_folder_element_popup}><Trash2 /></Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 h-full overflow-y-auto bg-stone-750 rounded-xl">
|
||||
<div class="flex flex-col gap-2 p-2 min-h-0 max-w-full">
|
||||
{#if $selected_display_ids.length === 0}
|
||||
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center">
|
||||
Es sind keine Bildschirme ausgewählt.
|
||||
</span>
|
||||
{:else}
|
||||
{#each $current_folder_elements || [] as folder_element (get_file_primary_key(folder_element))}
|
||||
<section in:slide={{ duration: 100 }} class="outline-none">
|
||||
<InodeElement file={folder_element} />
|
||||
</section>
|
||||
{/each}
|
||||
{#if ($current_folder_elements || []).length === 0}
|
||||
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center max-w-full">
|
||||
Es existieren keine Dateien auf {$selected_display_ids.length === 1
|
||||
? 'dem ausgewähltem Bildchirm'
|
||||
: 'den ausgewählten Bildschirmen'} im aktuellen Ordner. Klicke auf <HighlightedText
|
||||
bg="bg-stone-700"
|
||||
fg="text-stone-400"
|
||||
className="!p-1"><Upload class="inline pb-1" /></HighlightedText
|
||||
> um Datei(en) hochzuladen.
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<PopUp
|
||||
content={popup_content}
|
||||
close_function={popup_close_function}
|
||||
className="rounded-b-2xl"
|
||||
snippet_container_class="overflow-hidden min-w-90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, Ban, FileIcon, Folder, Play } from 'lucide-svelte';
|
||||
import {
|
||||
current_height,
|
||||
get_selectable_color_classes,
|
||||
get_shifted_color
|
||||
} from '$lib/ts/stores/ui_behavior';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { supported_file_type_icon, type Inode, get_file_primary_key } from '$lib/ts/types';
|
||||
|
||||
import {
|
||||
is_selected,
|
||||
select,
|
||||
selected_display_ids,
|
||||
selected_file_ids
|
||||
} from '$lib/ts/stores/select';
|
||||
import {
|
||||
change_file_path,
|
||||
current_file_path,
|
||||
get_missing_colliding_display_ids
|
||||
} from '$lib/ts/stores/files';
|
||||
import RefreshPlay from './RefreshPlay.svelte';
|
||||
import { get_file_size_display_string, get_file_type } from '$lib/ts/utils';
|
||||
import { open_file } from '$lib/ts/api_handler';
|
||||
import { run_on_all_selected_displays } from '$lib/ts/stores/displays';
|
||||
import { get_thumbnail_url } from '$lib/ts/stores/thumbnails';
|
||||
import { liveQuery } from 'dexie';
|
||||
|
||||
let { file, not_interactable = false }: { file: Inode; not_interactable?: boolean } = $props();
|
||||
|
||||
let missing_colliding_displays_ids = liveQuery(() =>
|
||||
get_missing_colliding_display_ids(file, $selected_display_ids)
|
||||
);
|
||||
let thumbnail_url = liveQuery(() => get_thumbnail_url(file));
|
||||
|
||||
const is_folder = file.type === 'inode/directory';
|
||||
|
||||
function get_created_string(date_object: Date | null, full_string = false) {
|
||||
if (!date_object) {
|
||||
return full_string ? 'Verschiedene Daten auf verschiedenen Bildschirmen' : 'versch.';
|
||||
}
|
||||
|
||||
if (full_string) {
|
||||
return (
|
||||
get_formated_date_string(date_object, true) + ' ' + get_formated_time_string(date_object)
|
||||
);
|
||||
} else if (date_object.toDateString() === new Date().toDateString()) {
|
||||
return get_formated_time_string(date_object);
|
||||
} else {
|
||||
return get_formated_date_string(date_object);
|
||||
}
|
||||
}
|
||||
|
||||
function get_formated_time_string(date_object: Date) {
|
||||
return `${date_object.getHours().toString().padStart(2, '0')}:${date_object.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function get_formated_date_string(date_object: Date, full_year = false) {
|
||||
return `${date_object.getDate().toString().padStart(2, '0')}.${(date_object.getMonth() + 1).toString().padStart(2, '0')}.${date_object
|
||||
.getFullYear()
|
||||
.toString()
|
||||
.slice(full_year ? 0 : 2)}`;
|
||||
}
|
||||
|
||||
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, get_file_primary_key(file), 'toggle');
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
async function open() {
|
||||
if (is_folder) {
|
||||
await change_file_path($current_file_path + file.name + '/');
|
||||
} else {
|
||||
const path_to_file = $current_file_path + file.name;
|
||||
await run_on_all_selected_displays(open_file, true, path_to_file);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div data-testid="inode" class="flex flex-row h-{$current_height.file} w-full">
|
||||
{#if !not_interactable}
|
||||
<div class="h-{$current_height.file} aspect-square max-w-15 flex">
|
||||
<Button
|
||||
disabled={!is_folder && get_file_type(file) === null}
|
||||
title={!is_folder && get_file_type(file) === null ? 'Dateityp nicht unterstützt' : ''}
|
||||
className="flex rounded-l-lg rounded-r-none {is_folder
|
||||
? 'text-stone-450'
|
||||
: 'text-stone-800'} w-full"
|
||||
div_class="w-full"
|
||||
bg={get_selectable_color_classes(
|
||||
!is_folder && get_file_type(file) !== null,
|
||||
{
|
||||
bg: true
|
||||
},
|
||||
-50
|
||||
)}
|
||||
hover_bg={get_selectable_color_classes(
|
||||
!is_folder,
|
||||
{
|
||||
bg: true
|
||||
},
|
||||
50
|
||||
)}
|
||||
active_bg={get_selectable_color_classes(
|
||||
!is_folder,
|
||||
{
|
||||
bg: true
|
||||
},
|
||||
100
|
||||
)}
|
||||
click_function={(e) => {
|
||||
open();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{#if is_folder}
|
||||
<ArrowRight class="size-full" strokeWidth="3" />
|
||||
{:else if $missing_colliding_displays_ids && $missing_colliding_displays_ids.missing.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"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onclick(e);
|
||||
}}
|
||||
{onclick}
|
||||
class="{get_selectable_color_classes(
|
||||
!not_interactable && is_selected(get_file_primary_key(file), $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">
|
||||
{#if is_folder}
|
||||
<Folder class="size-full p-2" />
|
||||
{:else if $thumbnail_url || null}
|
||||
<img
|
||||
src={$thumbnail_url || null}
|
||||
alt="file_thumbnail"
|
||||
class="object-contain size-full select-none block p-1 rounded-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{:else if supported_file_type_icon[get_file_type(file)?.display_name || '']}
|
||||
{@const Icon = supported_file_type_icon[get_file_type(file)?.display_name || '']}
|
||||
<Icon class="size-full p-2" />
|
||||
{:else}
|
||||
<FileIcon class="size-full p-2" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="content-center truncate select-none w-full" title={file.name}>
|
||||
{file.name.includes('.') && !is_folder && get_file_type(file)
|
||||
? file.name.slice(0, file.name.lastIndexOf('.'))
|
||||
: file.name}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class=" p-1 flex flex-row items-center gap-1 pr-1 {get_grayed_out_text_color_strings(
|
||||
is_selected(get_file_primary_key(file), $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}
|
||||
<Button
|
||||
className="h-8 aspect-square transition-colors duration-200 !p-1.5 text-stone-100"
|
||||
bg="bg-red-500"
|
||||
click_function={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<TriangleAlert class="size-full" />
|
||||
</Button>
|
||||
{:else if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[0].length !== 0}
|
||||
<Button
|
||||
className="h-8 aspect-square transition-colors duration-200 !p-1.5"
|
||||
bg="bg-transparent"
|
||||
hover_bg={get_selectable_color_classes(false, {
|
||||
bg: true
|
||||
})}
|
||||
active_bg={get_selectable_color_classes(false, {
|
||||
bg: true
|
||||
})}
|
||||
click_function={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<RefreshCcwDot class="size-full" />
|
||||
</Button>
|
||||
{/if} -->
|
||||
<div
|
||||
class="w-14 content-center text-center select-none text-xs whitespace-nowrap"
|
||||
title={get_created_string(file.date_created, true)}
|
||||
>
|
||||
{get_created_string(file.date_created)}
|
||||
</div>
|
||||
<div
|
||||
class="h-[70%] border {get_grayed_out_border_color_strings(
|
||||
is_selected(get_file_primary_key(file), $selected_file_ids)
|
||||
)} duration-200 transition-colors my-1"
|
||||
></div>
|
||||
<div
|
||||
class="w-12 content-center text-center select-none text-xs whitespace-nowrap truncate"
|
||||
title={file.type}
|
||||
>
|
||||
{is_folder ? 'Ordner' : (get_file_type(file)?.display_name ?? '?')}
|
||||
</div>
|
||||
<div
|
||||
class="h-[70%] border {get_grayed_out_border_color_strings(
|
||||
is_selected(get_file_primary_key(file), $selected_file_ids)
|
||||
)} duration-200 transition-colors"
|
||||
></div>
|
||||
<div
|
||||
class="w-12 content-center text-center select-none text-xs whitespace-nowrap"
|
||||
title={get_file_size_display_string(file.size, 3)}
|
||||
>
|
||||
{get_file_size_display_string(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { flip } from 'svelte/animate';
|
||||
import { get_selectable_color_classes } from '$lib/ts/stores/ui_behavior';
|
||||
import key_map_json from './../../../../shared/keys.json';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { run_on_all_selected_displays } from '$lib/ts/stores/displays';
|
||||
import { send_keyboard_input } from '$lib/ts/api_handler';
|
||||
|
||||
const key_map: Record<string, string> = key_map_json as Record<string, string>;
|
||||
|
||||
let active = $state(false);
|
||||
let last_keys: { id: number; key: string }[] = $state([]);
|
||||
|
||||
let el: HTMLDivElement;
|
||||
|
||||
function add_to_last_keys(name: string) {
|
||||
const id = Date.now();
|
||||
last_keys.push({ id, key: name });
|
||||
setTimeout(() => {
|
||||
last_keys = last_keys.filter((e) => e.id !== id);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function on_key_down(e: KeyboardEvent) {
|
||||
if (!active) return;
|
||||
const id = key_map[e.code];
|
||||
if (!id) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
add_to_last_keys(e.code);
|
||||
if (e.repeat) return;
|
||||
|
||||
await run_on_all_selected_displays(send_keyboard_input, true, id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
bind:this={el}
|
||||
onclick={() => {
|
||||
if (active) {
|
||||
el.blur();
|
||||
} else {
|
||||
el.focus();
|
||||
active = true;
|
||||
}
|
||||
}}
|
||||
onblur={() => (active = false)}
|
||||
onkeydown={on_key_down}
|
||||
class="relative flex justify-center items-center h-15 w-full cursor-pointer rounded-xl transition-colors duration-200 select-none {get_selectable_color_classes(
|
||||
active,
|
||||
{
|
||||
bg: true,
|
||||
hover: true,
|
||||
active: true,
|
||||
text: true
|
||||
}
|
||||
)}"
|
||||
>
|
||||
{active ? 'Erfassung aktiv' : 'Hier für Erfassung klicken'}
|
||||
<div class="absolute top-full left-0 ml-1 mt-0.5 flex flex-col-reverse text-sm text-stone-400">
|
||||
{#each last_keys as key (key.id)}
|
||||
<span
|
||||
animate:flip={{ duration: 200 }}
|
||||
in:fade={{ duration: 200 }}
|
||||
out:fade={{ duration: 500 }}>{key.key}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { X } from 'lucide-svelte';
|
||||
import { notifications } from '$lib/ts/stores/notification';
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="notification"
|
||||
class="fixed flex flex-col gap-2 top-[41%] right-2 left-2 md:top-auto md:left-auto md:bottom-2 md:w-100 z-50"
|
||||
>
|
||||
{#each $notifications as n (n.id)}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="p-2 pl-4 pb-3 rounded-lg shadow-xl/30 text-white flex flex-col gap-2 overflow-hidden relative border border-black/20 {n.className}"
|
||||
class:bg-red-900={n.type === 'error'}
|
||||
class:bg-green-900={n.type === 'success'}
|
||||
class:bg-sky-900={n.type === 'info'}
|
||||
style="--dur: {n.duration}ms"
|
||||
>
|
||||
<div class="flex flex-row justify-between">
|
||||
<span class="text-xl font-bold flex items-center">{n.title}</span>
|
||||
<Button
|
||||
click_function={() => notifications.remove(n.id)}
|
||||
className="p-2"
|
||||
bg="bg-stone-900/50"
|
||||
hover_bg="bg-stone-600/70"
|
||||
active_bg="bg-stone-500/80"><X /></Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<span class="whitespace-break-spaces">{n.message}</span>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 h-1 bg-white/25">
|
||||
<div class="block h-full w-full bg-white/80 origin-left animate-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes progress {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
.animate-progress-bar {
|
||||
animation: progress var(--dur) linear forwards;
|
||||
transform-origin: left;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { type DisplayStatus } from '$lib/ts/types';
|
||||
import { display_status_to_info } from '$lib/ts/utils';
|
||||
|
||||
let {
|
||||
selected,
|
||||
status,
|
||||
className = ''
|
||||
}: {
|
||||
selected: boolean;
|
||||
status: DisplayStatus;
|
||||
className?: string;
|
||||
} = $props();
|
||||
|
||||
function get_text_color(selected: boolean, status: DisplayStatus) {
|
||||
switch (status) {
|
||||
case 'app_online':
|
||||
return selected ? 'text-green-700' : 'text-green-400';
|
||||
case 'app_offline':
|
||||
return selected ? 'text-amber-700' : 'text-amber-400';
|
||||
case 'host_offline':
|
||||
return selected ? 'text-red-700' : 'text-red-400';
|
||||
default:
|
||||
return selected ? 'text-stone-700' : 'text-stone-400';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="{get_text_color(selected, status)} {className} transition-colors duration-100">
|
||||
{display_status_to_info(status)}
|
||||
</div>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { ChevronRight, House } from 'lucide-svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { MenuOption } from '$lib/ts/types';
|
||||
import { change_file_path, current_file_path } from '$lib/ts/stores/files';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
let {
|
||||
bg = 'bg-stone-700'
|
||||
}: {
|
||||
bg?: string;
|
||||
} = $props();
|
||||
|
||||
let outside_container: HTMLDivElement;
|
||||
let inside_container: HTMLDivElement;
|
||||
let resize_observer: ResizeObserver;
|
||||
let w10_div: HTMLDivElement;
|
||||
|
||||
let cut_folders: number = $state(0);
|
||||
// let folders_length: number = $state(get_folders($current_file_path).length);
|
||||
// let all_folders_length: number = $state(get_folders($current_file_path).length);
|
||||
|
||||
function get_folders(path: string): string[] {
|
||||
path = path.slice(1); // Cut first '/'
|
||||
if (path === '') return [];
|
||||
if (path.endsWith('/')) {
|
||||
path = path.slice(0, path.length - 1);
|
||||
}
|
||||
return path.split('/');
|
||||
}
|
||||
|
||||
function get_sliced_folders(path: string, cut_folders: number): string[] {
|
||||
const folders = get_folders(path);
|
||||
let sliced_folders: string[] = [];
|
||||
if (cut_folders !== get_folders($current_file_path).length)
|
||||
sliced_folders = folders.slice(cut_folders);
|
||||
return sliced_folders;
|
||||
}
|
||||
|
||||
function get_hidden_folders(path: string, cut_folders: number): string[] {
|
||||
const folders = get_folders(path);
|
||||
let hidden_folders: string[] = [];
|
||||
if (cut_folders !== get_folders($current_file_path).length)
|
||||
hidden_folders = folders.slice(0, cut_folders);
|
||||
return hidden_folders;
|
||||
}
|
||||
|
||||
function recalc() {
|
||||
if (!outside_container || !inside_container || !w10_div) return;
|
||||
const first_shrink = cut_folders === 0;
|
||||
const second_last_grow = cut_folders === 2;
|
||||
|
||||
const difference = outside_container.offsetWidth - inside_container.offsetWidth;
|
||||
|
||||
const w10_px = parseFloat(getComputedStyle(w10_div).width);
|
||||
|
||||
if ((!first_shrink && difference < 2 * w10_px) || (first_shrink && difference < w10_px)) {
|
||||
if (cut_folders < get_folders($current_file_path).length) {
|
||||
cut_folders += first_shrink ? 2 : 1;
|
||||
}
|
||||
} else if (
|
||||
(!second_last_grow && difference > 6 * w10_px) ||
|
||||
(second_last_grow && difference > 7 * w10_px)
|
||||
) {
|
||||
if (cut_folders >= 1) {
|
||||
if (second_last_grow) {
|
||||
cut_folders -= 2;
|
||||
} else {
|
||||
cut_folders -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function get_hidden_menu_options(hidden_folders: string[], path: string): MenuOption[] {
|
||||
const out: MenuOption[] = [];
|
||||
for (let i = 0; i < hidden_folders.length; i++) {
|
||||
out.push({
|
||||
name: ' '.repeat(i) + hidden_folders[i],
|
||||
class: 'truncate max-w-80',
|
||||
on_select: async () => {
|
||||
await open_path(i + 1, path);
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function open_path(index_of_all_folders: number, path: string) {
|
||||
let new_path = '/';
|
||||
const all_folders = get_folders(path);
|
||||
for (let i = 0; i < index_of_all_folders; i++) {
|
||||
new_path += all_folders[i] + '/';
|
||||
}
|
||||
await change_file_path(new_path);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
resize_observer = new ResizeObserver(() => recalc());
|
||||
resize_observer.observe(outside_container);
|
||||
resize_observer.observe(inside_container);
|
||||
// initial
|
||||
setTimeout(recalc, 0);
|
||||
});
|
||||
onDestroy(() => resize_observer?.disconnect());
|
||||
</script>
|
||||
|
||||
<div class="{bg} rounded-xl flex" bind:this={outside_container}>
|
||||
<div class="flex flex-row">
|
||||
<div class="flex flex-row">
|
||||
<div bind:this={w10_div} class="flex">
|
||||
<Button
|
||||
className="py-1 shrink-0 grow-0 w-10"
|
||||
{bg}
|
||||
click_function={async () => {
|
||||
await open_path(0, $current_file_path);
|
||||
}}
|
||||
>
|
||||
<House
|
||||
class="size-full transition-all duration-100"
|
||||
strokeWidth={$current_file_path === '/' ? 2.7 : 2}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{#if cut_folders !== 0}
|
||||
<Button
|
||||
className="pl-0 py-1 grow"
|
||||
{bg}
|
||||
menu_options={get_hidden_menu_options(
|
||||
get_hidden_folders($current_file_path, cut_folders),
|
||||
$current_file_path
|
||||
)}
|
||||
menu_class="left-0"
|
||||
>
|
||||
<ChevronRight class="shrink-0 text-stone-500 h-full" />
|
||||
<span class="font-bold" title="Weiteren Pfad zeigen"> ... </span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row" bind:this={inside_container}>
|
||||
{#each get_sliced_folders($current_file_path, cut_folders) as folder, i (i)}
|
||||
<div animate:flip={{ duration: 100, easing: cubicOut }}>
|
||||
<Button
|
||||
className="shrink-0 py-1 pl-0 {i ===
|
||||
get_sliced_folders($current_file_path, cut_folders).length - 1
|
||||
? 'max-w-80 font-bold'
|
||||
: 'max-w-30'}"
|
||||
{bg}
|
||||
click_function={async () => {
|
||||
await open_path(cut_folders + i + 1, $current_file_path);
|
||||
}}
|
||||
>
|
||||
<ChevronRight class="shrink-0 text-stone-500 h-full" />
|
||||
<span class="truncate" title={folder}>
|
||||
{folder}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
let { className = '' }: { className?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.7"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="{className} lucide lucide-refresh-ccw-dot-icon lucide-refresh-ccw-dot"
|
||||
>
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
<path
|
||||
d="M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z"
|
||||
transform="translate(0,2.0)"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,255 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import {
|
||||
Baseline,
|
||||
Bold,
|
||||
Highlighter,
|
||||
Italic,
|
||||
PaintBucket,
|
||||
QrCode,
|
||||
Strikethrough
|
||||
} from 'lucide-svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { run_on_all_selected_displays } from '$lib/ts/stores/displays';
|
||||
import { show_html } from '$lib/ts/api_handler';
|
||||
import { get_selectable_color_classes } from '$lib/ts/stores/ui_behavior';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-text-style';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
|
||||
type TextEditOption = {
|
||||
onclick: () => void;
|
||||
is_selected: () => boolean;
|
||||
icon: typeof Bold;
|
||||
title: string;
|
||||
color?: ColorElement;
|
||||
};
|
||||
|
||||
type ColorElement = {
|
||||
color_string: () => string;
|
||||
color_picker: () => void;
|
||||
};
|
||||
|
||||
type ColorState = {
|
||||
el: HTMLInputElement | null;
|
||||
value: string;
|
||||
};
|
||||
|
||||
let element = $state<HTMLElement>();
|
||||
let editor_state = $state<{ editor: Editor | null }>({ editor: null });
|
||||
|
||||
let color_states: { text: ColorState; highlight: ColorState; bg: ColorState } = $state({
|
||||
text: { el: null, value: '#b91c1c' },
|
||||
highlight: { el: null, value: '#0c4a6e' },
|
||||
bg: { el: null, value: '#1c1917' }
|
||||
});
|
||||
|
||||
const text_edit_options: TextEditOption[][] = [
|
||||
[
|
||||
{
|
||||
onclick: () => editor_state.editor?.chain().focus().toggleBold().run(),
|
||||
is_selected: () => editor_state.editor?.isActive('bold') ?? false,
|
||||
title: 'Fett (STRG+B)',
|
||||
icon: Bold
|
||||
},
|
||||
{
|
||||
onclick: () => editor_state.editor?.chain().focus().toggleItalic().run(),
|
||||
is_selected: () => editor_state.editor?.isActive('italic') ?? false,
|
||||
title: 'Kursiv (STRG+I)',
|
||||
icon: Italic
|
||||
},
|
||||
{
|
||||
onclick: () => editor_state.editor?.chain().focus().toggleStrike().run(),
|
||||
is_selected: () => editor_state.editor?.isActive('strike') ?? false,
|
||||
title: 'Durchgestrichen',
|
||||
icon: Strikethrough
|
||||
},
|
||||
{
|
||||
onclick: () => {},
|
||||
is_selected: () => false,
|
||||
title: 'QR-Code anfügen',
|
||||
icon: QrCode
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
onclick: () => editor_state.editor?.chain().focus().setColor(color_states.text.value).run(),
|
||||
is_selected: () =>
|
||||
editor_state.editor?.isActive('textStyle', { color: color_states.text.value }) ?? false,
|
||||
icon: Baseline,
|
||||
title: 'Textfarbe',
|
||||
color: get_color_element(color_states.text)
|
||||
},
|
||||
{
|
||||
onclick: () =>
|
||||
editor_state.editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.toggleHighlight({ color: color_states.highlight.value })
|
||||
.run(),
|
||||
is_selected: () =>
|
||||
editor_state.editor?.isActive('highlight', { color: color_states.highlight.value }) ??
|
||||
false,
|
||||
icon: Highlighter,
|
||||
title: 'Markierungsfarbe',
|
||||
color: get_color_element(color_states.highlight)
|
||||
},
|
||||
{
|
||||
onclick: () => color_states.bg.el?.click(),
|
||||
is_selected: () => false,
|
||||
title: 'Hintergrundfarbe',
|
||||
icon: PaintBucket
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
function get_color_element(color_state: ColorState) {
|
||||
return {
|
||||
color_string: () => color_state.value,
|
||||
color_picker: () => color_state.el?.click()
|
||||
};
|
||||
}
|
||||
|
||||
async function show_text() {
|
||||
const html =
|
||||
editor_state.editor?.getHTML() +
|
||||
`<style>:root {--background-color: ${color_states.bg.value} !important;}
|
||||
</style>`;
|
||||
await run_on_all_selected_displays(show_html, true, html);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor_state.editor = new Editor({
|
||||
element: element,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({
|
||||
placeholder: 'Text hier eingeben ...'
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
Highlight.configure({
|
||||
multicolor: true
|
||||
})
|
||||
],
|
||||
content: '',
|
||||
onTransaction: ({ editor }) => {
|
||||
// Increment the state signal to force a re-render
|
||||
editor_state = { editor };
|
||||
},
|
||||
autofocus: true
|
||||
});
|
||||
});
|
||||
onDestroy(() => {
|
||||
editor_state.editor?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each Object.values(color_states) as color_state}
|
||||
<input type="color" bind:this={color_state.el} bind:value={color_state.value} class="hidden" />
|
||||
{/each}
|
||||
|
||||
<div data-testid="text-popup" class="flex flex-row gap-2 size-full">
|
||||
<div
|
||||
class="rounded-xl size-full shrink min-w-0 flex"
|
||||
style="background-color: {color_states.bg.value};"
|
||||
>
|
||||
<div bind:this={element} class="size-full overflow-auto px-3 py-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each text_edit_options as edit_row}
|
||||
<div class="flex flex-row gap-1">
|
||||
{#each edit_row as option}
|
||||
<div class="flex flex-row">
|
||||
<button
|
||||
title={option.title}
|
||||
onclick={option.onclick}
|
||||
class="p-1 {option.color
|
||||
? 'rounded-l-xl'
|
||||
: 'rounded-xl'} cursor-pointer duration-200 transition-colors {get_selectable_color_classes(
|
||||
option.is_selected(),
|
||||
{
|
||||
bg: true,
|
||||
hover: true,
|
||||
active: true,
|
||||
text: true
|
||||
},
|
||||
-100,
|
||||
50
|
||||
)}"
|
||||
>
|
||||
{#if option.icon}
|
||||
{@const Icon = option.icon}
|
||||
<Icon class="h-7 w-7" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if option.color}
|
||||
<button
|
||||
aria-label="Color"
|
||||
onclick={option.color.color_picker}
|
||||
title={option.title}
|
||||
class="flex p-1 rounded-r-xl cursor-pointer duration-200 transition-colors justify-center items-center {get_selectable_color_classes(
|
||||
option.is_selected(),
|
||||
{
|
||||
bg: true,
|
||||
hover: true,
|
||||
active: true,
|
||||
text: true
|
||||
},
|
||||
-100,
|
||||
50
|
||||
)}"
|
||||
>
|
||||
<span
|
||||
class="h-7 w-3 rounded-full"
|
||||
style="background-color: {option.color.color_string()};"
|
||||
></span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Button click_function={show_text} className="w-full font-bold">Text anzeigen</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.tiptap) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
}
|
||||
|
||||
:global(.tiptap ul),
|
||||
:global(.tiptap ol) {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.tiptap ul) {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
:global(.tiptap ol) {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
:global(.tiptap p) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
:global(.tiptap p.is-editor-empty:first-child::before) {
|
||||
content: attr(data-placeholder);
|
||||
pointer-events: none;
|
||||
color: oklch(44.4% 0.011 73.639);
|
||||
float: left;
|
||||
height: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
Reference in New Issue
Block a user