Compare commits

...

65 Commits

Author SHA1 Message Date
2mal3 89ce7402df chore(display/keyboardInput): follow new docs [untested] 2026-01-09 11:21:02 +01:00
E44 45e8e87b35 chore(control): improve logging in file_transfer_handler 2026-01-08 19:41:20 +01:00
E44 e308d6a42a chore(control): add sync functionality [untested] 2026-01-08 19:36:37 +01:00
E44 bfcc109c80 fix(control): fix missing refactoring of loading_data 2026-01-08 18:56:20 +01:00
E44 f9f6d84381 refactor(control): remove unnecessary comments 2026-01-08 18:40:13 +01:00
E44 1e42f390f5 refactor(control): remove downloads from tasks 2026-01-08 18:31:44 +01:00
E44 754f466d73 chore(control): change to new keyboard_input api 2026-01-08 18:05:58 +01:00
2mal3 1bd76c666d docs(display): more efficent keyboard input api 2026-01-08 10:52:25 +01:00
2mal3 46e59d35bf chore(control/run_on_all_selected_displays): parallel processing 2026-01-06 19:58:24 +01:00
2mal3 0614746d95 chore(control): return request result in power apis 2026-01-06 19:43:13 +01:00
2mal3 f76337efc7 feat(control): startup button 2026-01-06 19:41:15 +01:00
2mal3 f7b0c9978d chore(control/un_on_all_selected_displays): allow ignoring of offline check 2026-01-06 19:39:44 +01:00
2mal3 3389a3bf50 docs(control): wake on lan bruno requst 2026-01-06 19:26:33 +01:00
2mal3 58570552eb feat(control): put displays in loading state when shutting down 2026-01-06 19:20:29 +01:00
2mal3 427209e518 refactor(control): make run_on_all_selected_displays more generic 2026-01-06 19:18:18 +01:00
2mal3 4325ffc4aa refactor(control): rename file_display.db to database 2026-01-06 19:04:23 +01:00
2mal3 c4d7f8e0e4 fix(control): toggle select all 2026-01-05 23:14:25 +01:00
2mal3 ef64330050 chore(control): default bg color for settings button 2026-01-05 23:05:02 +01:00
2mal3 c7b073eb34 refactor(control): use error button class more 2026-01-05 23:00:13 +01:00
2mal3 58e56ffae3 feat(control): shutdown 2026-01-05 23:00:13 +01:00
E44 0c619bdc1d refactor(control): remove unnecessary statement 2026-01-05 22:46:17 +01:00
E44 cc958581bf chore(control): add download function 2026-01-05 22:45:00 +01:00
2mal3 aa0f88b258 chore(control): run only on all online selected displays 2026-01-05 21:42:26 +01:00
2mal3 28121df128 feat(control): hide unusable power options 2026-01-05 21:42:26 +01:00
E44 6f0cb7298a refactor(control): improve file transfer backend data structures 2026-01-05 21:40:11 +01:00
E44 6f0b05ca67 chore(control): added upload finished animation 2026-01-05 19:09:53 +01:00
E44 a8a59bd543 fix(control): replaced overflow-auto with overflow-y-auto in notifications 2026-01-05 19:09:34 +01:00
E44 b6be6bd9c4 fix(control): only one upload was possible 2026-01-05 18:37:41 +01:00
E44 12e500e988 refactor(control): improve db update of upload percentage 2026-01-05 18:29:05 +01:00
E44 b775fae9c0 refactor(control): remove unnecessary logging 2026-01-04 23:27:54 +01:00
E44 2367dc0c9e chore(control): add progress_bar in InodeElement 2026-01-04 23:25:19 +01:00
E44 fa42feb808 chore(control): increase z-level of PopUp 2026-01-04 23:24:28 +01:00
E44 fa16bb699c fix(control): uploaded_percentage was normally set to 0 2026-01-04 23:23:49 +01:00
E44 44290f46fe chore(control + display): improve api urls, paths and file_names 2026-01-04 23:22:59 +01:00
E44 a2c1f385c1 refactor(control): undo last commit 2026-01-04 23:01:10 +01:00
2mal3 8b41711df8 refactor: clearer log messages 2026-01-04 21:31:23 +01:00
2mal3 bdfc648f51 chore(control): only try getting description if json response 2026-01-04 21:29:38 +01:00
2mal3 a49d10521e fix(control): blackout broken 2026-01-04 21:13:35 +01:00
2mal3 c8920df9b3 chore: better logging
log some things only in dev mode
2026-01-04 21:02:14 +01:00
E44 9843565c7b chore(control): add basic upload logic 2026-01-04 18:27:54 +01:00
2mal3 5f9bd20c06 docs: add GNU AGPL v3 license 2026-01-04 02:09:29 +01:00
E44 18eddde385 fix(display): fix bruno send_keyboard_input json body 2026-01-03 16:46:01 +01:00
E44 628e7cb5c6 fix(control): fixed date_created display strings 2026-01-03 16:09:16 +01:00
E44 56ddddf919 fix(control): removed date_created completly from Inode and fixed the displayed created date in InodeElement 2026-01-03 15:55:17 +01:00
E44 3f7fc30ce2 fix(control): remove empty menu object on every button 2026-01-03 15:15:05 +01:00
E44 0a35fcc543 chore(control): improve zoom-in/zoom-out buttons 2026-01-03 15:13:35 +01:00
E44 5531ff0ffa refactor(control): move components from /routes to /components and /svgs 2026-01-03 15:04:59 +01:00
E44 9072029945 fix(control): close popups immediately after submitting -> ui feels faster and second input is not longer possible 2026-01-03 15:01:55 +01:00
E44 a7b38d88de fix(control): run run_for_selected_files_on_selected_displays only for selected files, not for all files 2026-01-03 14:54:15 +01:00
E44 19337de98f chore(control): improve scrollbar look 2026-01-03 14:42:19 +01:00
E44 5464b829f0 fix(control): add maximum notification height and add optional scrollbar 2026-01-03 12:51:38 +01:00
2mal3 028e419535 fix(frontend): more reactive db problems
Co-Authored-By: E44 <129310925+programmer-44@users.noreply.github.com>
2026-01-02 16:50:52 +01:00
E44 2391169f46 fix(control): fix reactive db problem in current_folder_elements 2026-01-02 16:34:42 +01:00
2mal3 87c5a68127 refactor(frontend) 2025-12-29 19:02:58 +01:00
2mal3 e4bf1ccfc1 refactor(frontend): split true components from modules 2025-12-29 18:57:43 +01:00
2mal3 6da0d69125 refactor(frontend): remove test page 2025-12-29 18:39:11 +01:00
2mal3 abf227d15d refactor(frontent): move components and ts to lib folder 2025-12-29 18:38:20 +01:00
2mal3 54b9e1fc78 refactor(frontend): small syntax improvements 2025-12-29 18:26:54 +01:00
2mal3 3fc67dadd9 test(frontend): more tests 2025-12-29 18:21:30 +01:00
2mal3 7dbc746242 test(frontend): basic e2e testing framework 2025-12-29 17:30:25 +01:00
2mal3 ac75494f6d chore(frontend): require min dependency age for npm deps 2025-12-29 17:27:55 +01:00
2mal3 c6b0c1cf31 refactor(frontend): use db for files & displays
Co-Authored-By: E44 <129310925+programmer-44@users.noreply.github.com>
2025-12-29 16:28:26 +01:00
2mal3 880725a105 chore: nix dev shell support 2025-12-19 13:53:58 +01:00
2mal3 6a6c13018e chore(control): clearer view zoom button icons 2025-12-14 13:41:52 +01:00
2mal3 9ea4e31863 fix(nixos): screen blank after inactivity 2025-12-06 14:10:21 +01:00
66 changed files with 4233 additions and 2467 deletions
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+3
View File
@@ -21,3 +21,6 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# test
test-results/
+3
View File
@@ -0,0 +1,3 @@
{
"minimumDependencyAge": "P7D"
}
+486 -590
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
import { expect, test } from '@playwright/test';
test('page loads', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('PLG MuDiCS')).toBeVisible();
});
test('page loads without problems', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('PLG MuDiCS')).toBeVisible();
await expect(page.getByTestId('notification')).not.toBeVisible();
});
test('diplay click shows files', async ({ page }) => {
await page.goto('/');
await page.getByTestId('display').click();
await expect(page.getByTestId('inode').first()).toBeVisible();
});
test('show text', async ({ page }) => {
await page.goto('/');
await page.getByTestId('display').click();
const controlButton = page.getByText('Text anzeigen');
await expect(controlButton).toBeVisible();
await controlButton.click();
const textPopup = page.getByTestId('text-popup');
await expect(textPopup).toBeVisible();
const textArea = textPopup.getByRole('textbox');
await expect(textArea).toBeVisible();
await textArea.fill('Hello, world!');
const submitButton = textPopup.locator('button').filter({ hasText: 'Text anzeigen' });
await submitButton.click();
await expect(
page.locator('[data-testid="notification"]:not(:has-text("Fehler 500"))')
).not.toBeVisible();
});
+68 -22
View File
@@ -22,6 +22,7 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.9", "@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
@@ -36,7 +37,7 @@
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"sass-embedded": "^1.93.3", "sass-embedded": "^1.93.3",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.3.5",
"svelte-dnd-action": "^0.9.65", "svelte-dnd-action": "^0.9.65",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
@@ -1143,6 +1144,22 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1506,7 +1523,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 +1562,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 +1918,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 +2119,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 +2251,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 +2265,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 +2414,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 +2631,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 +3027,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 +4206,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"
}, },
@@ -4206,6 +4213,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -4226,7 +4280,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 +4399,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 +4532,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 +4561,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 +4609,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 +5271,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",
@@ -5244,9 +5292,9 @@
} }
}, },
"node_modules/svelte-check": { "node_modules/svelte-check": {
"version": "4.3.3", "version": "4.3.5",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz", "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz",
"integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==", "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5472,7 +5520,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 +5588,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",
+5 -2
View File
@@ -11,11 +11,14 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"format": "prettier --write ." "format": "prettier --write .",
"test:e2e": "playwright test",
"test": "deno task test:e2e"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@playwright/test": "1.56.1",
"@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.9", "@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
@@ -30,7 +33,7 @@
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"sass-embedded": "^1.93.3", "sass-embedded": "^1.93.3",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.3.5",
"svelte-dnd-action": "^0.9.65", "svelte-dnd-action": "^0.9.65",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
fullyParallel: true,
webServer: {
command: 'deno task build && deno task preview',
port: 4173
},
testDir: 'e2e'
});
+19
View File
@@ -12,6 +12,25 @@
--color-stone-850: oklch(0.242 0.0065 45.171); --color-stone-850: oklch(0.242 0.0065 45.171);
} }
.button {
&.space {
@apply px-4;
}
&.error {
@apply text-red-400 hover:text-stone-100;
&:hover {
@apply bg-red-400;
}
}
}
main {
scrollbar-width: thin;
scrollbar-color: var(--color-stone-300) transparent;
}
/* Splitpanes */ /* Splitpanes */
.splitpanes.mudics-stone-theme { .splitpanes.mudics-stone-theme {
.splitpanes__pane { .splitpanes__pane {
@@ -1,276 +0,0 @@
<script lang="ts">
import {
ArrowRight,
Ban,
FileIcon,
Folder,
Play,
RefreshCcwDot,
TriangleAlert
} from 'lucide-svelte';
import {
current_height,
get_selectable_color_classes,
get_shifted_color
} from '../ts/stores/ui_behavior';
import Button from './Button.svelte';
import {
supported_file_type_icon,
type FolderElement,
type SupportedFileType
} from '../ts/types';
import {
is_selected,
select,
selected_display_ids,
selected_file_ids
} from '../ts/stores/select';
import {
all_files,
change_file_path,
current_file_path,
get_display_ids_where_file_is_missing
} from '../ts/stores/files';
import RefreshPlay from './RefreshPlay.svelte';
import { get_file_size_display_string, get_file_type } from '../ts/utils';
import { open_file } from '../ts/api_handler';
import {
displays,
get_display_by_id,
run_on_all_selected_displays,
update_screenshot
} from '../ts/stores/displays';
import { get_thumbnail_url } from '../ts/stores/thumbnails';
import { db } from '../ts/indexdb/file_thumbnails.db';
import { onDestroy, onMount } from 'svelte';
import { liveQuery } from 'dexie';
let { file, not_interactable = false } = $props<{
file: FolderElement;
not_interactable?: boolean;
}>();
let thumbnail_url: string | null = $state(null);
// Update thumbnail_url automatically if data is available
const subscription = liveQuery(() => db.thumbnail_blobs.get(file.hash)).subscribe({
next: async () => {
thumbnail_url = await get_thumbnail_url(file.hash);
},
error: (err) => console.error('Dexie subscription error:', err)
});
onDestroy(() => subscription.unsubscribe());
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, file.id);
e.stopPropagation();
}
async function open() {
if (is_folder) {
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 class="flex flex-row h-{$current_height.file} w-full">
{#if !not_interactable}
<div class="h-{$current_height.file} aspect-square max-w-15 flex">
<Button
disabled={!is_folder && get_file_type(file) === null}
title={!is_folder && get_file_type(file) === null ? 'Dateityp nicht unterstützt' : ''}
className="flex rounded-l-lg rounded-r-none {is_folder
? 'text-stone-450'
: 'text-stone-800'} w-full"
div_class="w-full"
bg={get_selectable_color_classes(
!is_folder && get_file_type(file) !== null,
{
bg: true
},
-50
)}
hover_bg={get_selectable_color_classes(
!is_folder,
{
bg: true
},
50
)}
active_bg={get_selectable_color_classes(
!is_folder,
{
bg: true
},
100
)}
click_function={(e) => {
open();
e.stopPropagation();
}}
>
{#if is_folder}
<ArrowRight class="size-full" strokeWidth="3" />
{:else if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[0].length !== 0}
<RefreshPlay className="size-full" />
{:else if get_file_type(file) !== null}
<Play class="size-full" strokeWidth="3" />
{:else}
<Ban class="size-full" strokeWidth="3" />
{/if}
</Button>
</div>
{/if}
<div
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick(e);
}}
{onclick}
class="{get_selectable_color_classes(
!not_interactable && is_selected(file.id, $selected_file_ids),
{
bg: true,
hover: !not_interactable,
active: !not_interactable,
text: true
}
)} {not_interactable
? 'rounded-lg'
: 'rounded-r-lg cursor-pointer'} transition-colors duration-200 gap-4 flex flex-row justify-between group w-full min-w-0"
>
<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}
<img
src={thumbnail_url}
alt="file_thumbnail"
class="object-contain size-full select-none block p-1 rounded-lg"
draggable="false"
/>
{:else if supported_file_type_icon[get_file_type(file)?.display_name || '']}
{@const Icon = supported_file_type_icon[get_file_type(file)?.display_name || '']}
<Icon class="size-full p-2" />
{: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(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}
<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(file.id, $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(file.id, $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>
@@ -1,12 +0,0 @@
<script lang="ts">
let {
children,
bg = 'bg-stone-750',
fg = 'text-stone-200',
className = ''
} = $props<{ children: any; bg?: string; fg?: string; className?: string }>();
</script>
<div class="{bg} {fg} {className} rounded-lg py-0.5 px-1 inline-block truncate min-w-0 max-w-full align-middle my-[1.5px]">
{@render children()}
</div>
@@ -1,70 +0,0 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { get_selectable_color_classes } from '../ts/stores/ui_behavior';
import key_map_json from './../../../../shared/keys.json';
import { fade } from 'svelte/transition';
import { selected_display_ids } from '../ts/stores/select';
import { displays, get_display_by_id, run_on_all_selected_displays } from '../ts/stores/displays';
import { send_keyboard_input } from '../ts/api_handler';
const bg = 'bg-stone-700';
const key_map: Record<string, string> = key_map_json as Record<string, string>;
let active = $state(false);
let last_keys: { id: number; key: string }[] = $state([]);
let el: HTMLDivElement;
function add_to_last_keys(name: string) {
const id = Date.now();
last_keys.push({ id, key: name });
setTimeout(() => {
last_keys = last_keys.filter((e) => e.id !== id);
}, 1500);
}
async function on_key_down(e: KeyboardEvent) {
if (!active) return;
const id = key_map[e.code];
if (!id) return;
e.preventDefault();
e.stopPropagation();
add_to_last_keys(e.code);
if (e.repeat) return;
await run_on_all_selected_displays(send_keyboard_input, true, id);
}
</script>
<div
role="textbox"
tabindex="0"
bind:this={el}
onclick={() => {
if (active) {
el.blur();
} else {
el.focus();
active = true;
}
}}
onblur={() => (active = false)}
onkeydown={on_key_down}
class="relative flex justify-center items-center h-15 w-full cursor-pointer rounded-xl transition-colors duration-200 select-none {get_selectable_color_classes(
active,
{
bg: true,
hover: true,
active: true,
text: true
}
)}"
>
{active ? 'Erfassung aktiv' : 'Hier für Erfassung klicken'}
<div class="absolute top-full left-0 ml-1 mt-0.5 flex flex-col-reverse text-sm text-stone-400">
{#each last_keys as key (key.id)}
<span animate:flip={{ duration: 200 }} in:fade={{ duration: 200 }} out:fade={{ duration: 500 }} >{key.key}</span>
{/each}
</div>
</div>
@@ -1,48 +0,0 @@
<script lang="ts">
import { fade } from "svelte/transition";
import Button from "./Button.svelte";
import { X } from "lucide-svelte";
import { notifications } from "../ts/stores/notification";
import { get_shifted_color } from "../ts/stores/ui_behavior";
</script>
<div class="fixed flex flex-col gap-2 {true ? "top-[41%]": "top-2"} 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-1 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>
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { flip } from 'svelte/animate'; import { get_shifted_color } from '$lib/ts/stores/ui_behavior';
import { get_shifted_color } from '../ts/stores/ui_behavior'; import type { MenuOption } from '$lib/ts/types';
import type { MenuOption } from '../ts/types';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let { let {
@@ -11,12 +10,12 @@
active_bg = get_shifted_color(bg, 200), active_bg = get_shifted_color(bg, 200),
disabled = false, disabled = false,
title = '', title = '',
click_function = (e: MouseEvent) => {}, click_function = () => {},
menu_options = null, menu_options = [],
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;
@@ -24,11 +23,11 @@
disabled?: boolean; disabled?: boolean;
title?: string; title?: string;
click_function?: (e: MouseEvent) => void; click_function?: (e: MouseEvent) => void;
menu_options?: MenuOption[] | null; menu_options?: MenuOption[];
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;
@@ -36,7 +35,7 @@
let position_bottom = $state(true); let position_bottom = $state(true);
function onclick(e: MouseEvent) { function onclick(e: MouseEvent) {
if (menu_options !== null) { if (menu_options.length !== 0) {
if (menu_shown) { if (menu_shown) {
close_menu(); close_menu();
} else { } else {
@@ -166,8 +165,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();
}} }}
> >
@@ -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
@@ -5,56 +5,65 @@
get_selectable_color_classes, get_selectable_color_classes,
is_display_drag, is_display_drag,
is_group_drag is_group_drag
} from '../ts/stores/ui_behavior'; } from '$lib/ts/stores/ui_behavior';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
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, set_select_for_group,
set_new_display_group_data set_new_display_order
} from '../ts/stores/displays'; } from '$lib/ts/stores/displays';
import DNDGrip from './DNDGrip.svelte'; import DNDGrip from '$lib/components/DNDGrip.svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { DisplayGroup, MenuOption } from '../ts/types'; import type { MenuOption } from '$lib/ts/types';
import { selected_display_ids } from '../ts/stores/select'; import { selected_display_ids } from '$lib/ts/stores/select';
import { liveQuery, type Observable } from 'dexie';
let { display_group, get_display_menu_options, close_pinned_display } = $props<{ let {
display_group: DisplayGroup; display_group_id,
get_display_menu_options,
close_pinned_display
}: {
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: Observable<boolean> | undefined = $state();
$effect(() => {
const sdi = $selected_display_ids;
all_selected = liveQuery(() => all_displays_of_group_selected(display_group_id, sdi));
});
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() { async function select_all_of_this_group() {
const new_value = !all_displays_of_group_selected(display_group, $selected_display_ids); const new_value = !($all_selected || false);
select_all_of_group(display_group, new_value); await set_select_for_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 +77,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 +85,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 +96,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 +104,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
}, },
@@ -1,44 +1,51 @@
<script lang="ts"> <script lang="ts">
import Button from './Button.svelte'; import Button from '$lib/components/Button.svelte';
import { import {
current_height, current_height,
get_selectable_color_classes, get_selectable_color_classes,
pinned_display_id pinned_display_id
} from '../ts/stores/ui_behavior'; } from '$lib/ts/stores/ui_behavior';
import DNDGrip from './DNDGrip.svelte'; import DNDGrip from '$lib/components/DNDGrip.svelte';
import { Menu, Pencil, Pin, PinOff, Trash2, VideoOff, X } from 'lucide-svelte'; import { Menu, Pin, PinOff, VideoOff } from 'lucide-svelte';
import OnlineState from './OnlineState.svelte'; import OnlineState from './OnlineState.svelte';
import type { Display, MenuOption } from '../ts/types'; import type { Display, MenuOption } from '$lib/ts/types';
import { is_selected, select, selected_display_ids } from '../ts/stores/select'; import { is_selected, select, selected_display_ids } from '$lib/ts/stores/select';
import { update_screenshot } from '../ts/stores/displays'; import { screenshot_loop } from '$lib/ts/stores/displays';
import { filter_file_selection_for_current_selected_displays } from '../ts/stores/files'; import { change_file_path, current_file_path } from '$lib/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>
<div <div
data-testid="display"
role="button" role="button"
tabindex="0" tabindex="0"
onkeydown={(e) => { onkeydown={(e) => {
@@ -55,7 +62,7 @@
<div class="flex flex-row gap-4 min-w-0 flex-1"> <div class="flex flex-row gap-4 min-w-0 flex-1">
<!-- Left Preview Screen --> <!-- Left Preview Screen -->
<button <button
class="group relative aspect-16/9 bg-stone-800 h-full rounded-lg overflow-hidden cursor-pointer text-stone-200 transition-colors duration-200" 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)} onmouseenter={() => (hovering_unselectable = true)}
onmouseleave={() => (hovering_unselectable = false)} onmouseleave={() => (hovering_unselectable = false)}
onclick={on_preview_click} onclick={on_preview_click}
@@ -65,8 +72,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">
@@ -0,0 +1,19 @@
<script lang="ts">
let {
children,
bg = 'bg-stone-750',
fg = 'text-stone-200',
className = ''
}: {
children: any;
bg?: string;
fg?: string;
className?: string;
} = $props();
</script>
<div
class="{bg} {fg} {className} rounded-lg py-0.5 px-1 inline-block truncate min-w-0 max-w-full align-middle my-[1.5px]"
>
{@render children()}
</div>
+399
View File
@@ -0,0 +1,399 @@
<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,
type FileOnDisplay,
type FileLoadingData
} 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_date_mapping,
get_missing_colliding_display_ids
} from '$lib/ts/stores/files';
import RefreshPlay from '../svgs/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, type Observable } from 'dexie';
import { db } from '$lib/ts/database';
let { file, not_interactable = false }: { file: Inode; not_interactable?: boolean } = $props();
let missing_colliding_displays_ids:
| Observable<{ missing: string[]; colliding: string[] }>
| undefined = $state();
$effect(() => {
const s = $selected_file_ids;
missing_colliding_displays_ids = liveQuery(() => get_missing_colliding_display_ids(file, s));
});
let loading_data:
| Observable<{
is_loading: boolean;
total_percentage: number;
total_seconds_until_finish: number;
display_data: FileLoadingData[];
}>
| undefined = $state();
$effect(() => {
const d = $selected_display_ids;
loading_data = liveQuery(() => get_loading_data(get_file_primary_key(file), d));
});
let loading_finished = $state(false);
$effect(() => {
if (!loading_data) return;
let prev: boolean | undefined;
const sub = loading_data.subscribe((v) => {
if (prev === true && v.is_loading === false) {
loading_finished = true;
setTimeout(() => (loading_finished = false), 200);
}
prev = v.is_loading;
});
return () => sub.unsubscribe();
});
let thumbnail_url = liveQuery(() => get_thumbnail_url(get_file_primary_key(file)));
let date_mapping: Observable<Record<string, Date>> = liveQuery(() =>
get_date_mapping(get_file_primary_key(file))
);
const is_folder = file.type === 'inode/directory';
function get_created_info(date_mapping: Record<string, Date> | undefined, full_string = false) {
if (!date_mapping) return '';
const keys = Object.keys(date_mapping);
if (keys.length === 0) return '';
if (keys.length === 1) return get_formated_created_string(date_mapping[keys[0]], full_string);
let out = '';
let is_different = false;
const first_formated_created_string = get_formated_created_string(
date_mapping[keys[0]],
full_string
);
out += `${keys[0]}: ${first_formated_created_string}`;
for (const key of keys.splice(0, 1)) {
const current_formated_created_string = get_formated_created_string(
date_mapping[key],
full_string
);
if (!is_different && current_formated_created_string !== first_formated_created_string)
is_different = true;
out += `\n${key}: ${current_formated_created_string}`;
}
if (full_string) {
return is_different ? out : first_formated_created_string;
} else {
return is_different ? 'versch.' : first_formated_created_string;
}
}
function get_formated_created_string(date_object: Date, full_string: boolean) {
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';
if ($loading_data?.is_loading) return 'text-white/20';
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';
if ($loading_data?.is_loading) return 'border-white/10';
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 || $loading_data?.is_loading) 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((d) => open_file(d.ip, path_to_file));
}
}
function get_main_classes(): string {
let out = '';
if (loading_finished) {
out += 'bg-stone-500 text-white/30';
} else if ($loading_data?.is_loading) {
out += 'bg-stone-700 text-white/30';
} else {
out += 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
}
);
}
if (not_interactable) {
out += ' rounded-lg';
} else if ($loading_data?.is_loading) {
out += ' rounded-r-lg';
} else {
out += ' rounded-r-lg cursor-pointer';
}
return out;
}
async function get_loading_data(
file_primary_key: string,
selected_display_ids: string[]
): Promise<{
is_loading: boolean;
total_percentage: number;
total_seconds_until_finish: number;
display_data: FileLoadingData[];
}> {
const file_on_display_data: FileOnDisplay[] = await db.files_on_display
.where('file_primary_key')
.equals(file_primary_key)
.filter((e) => selected_display_ids.includes(e.display_id))
.toArray();
if (file_on_display_data.length === 0) {
return {
is_loading: true,
total_percentage: 0,
total_seconds_until_finish: -1,
display_data: []
};
}
const display_data = [];
let is_loading = false;
let percentage_sum = 0;
let total_seconds_until_finish = 0;
for (const fod of file_on_display_data) {
if (!!fod.loading_data) {
if (!is_loading) is_loading = true;
percentage_sum += fod.loading_data.percentage;
total_seconds_until_finish += fod.loading_data.seconds_until_finish;
display_data.push(fod.loading_data);
} else {
percentage_sum += 100;
}
}
let total_percentage = percentage_sum / display_data.length;
return {
is_loading,
total_percentage,
total_seconds_until_finish,
display_data
};
}
</script>
<div
data-testid="inode"
class="flex flex-row h-{$current_height.file} w-full {loading_finished
? 'scale-105'
: ''} transition-[scale] duration-300"
>
{#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_main_classes()} relative transition-colors duration-200 gap-4 flex flex-row justify-between group w-full min-w-0"
>
{#if $loading_data?.is_loading}
<!-- <div class="pointer-events-none absolute inset-0"> -->
<div
class="absolute pointer-events-none inset-y-0 left-0 transition-[width] duration-200 bg-stone-600 rounded-r-lg"
style={`width: ${$loading_data.total_percentage}%;`}
></div>
<!-- </div> -->
{/if}
<div class="flex flex-row gap-2 min-w-0 w-full z-10">
<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="ERR"
class="object-contain size-full select-none block p-1 rounded-lg text-center content-center text-red-300"
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 z-10 {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_info($date_mapping, true)}
>
{get_created_info($date_mapping)}
</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>
@@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
import { type DisplayStatus } from "../ts/types"; import { type DisplayStatus } from '$lib/ts/types';
import { display_status_to_info } from "../ts/utils"; import { display_status_to_info } from '$lib/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) {
@@ -1,21 +1,21 @@
<script lang="ts"> <script lang="ts">
import { X } from 'lucide-svelte'; import { X } from 'lucide-svelte';
import { onDestroy, onMount } from 'svelte';
import Button from './Button.svelte'; import Button from './Button.svelte';
import type { PopupContent } from '../ts/types'; import type { PopupContent } from '$lib/ts/types';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
let { let {
content, content,
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;
@@ -38,7 +38,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="absolute inset-0 backdrop-blur flex justify-center items-center {className}" class="absolute inset-0 backdrop-blur flex justify-center items-center z-50 {className}"
onclick={try_to_close} onclick={try_to_close}
transition:fade={{ duration: 100 }} transition:fade={{ duration: 100 }}
> >
@@ -52,20 +52,20 @@
{#if content.title} {#if content.title}
<div class="font-bold bg-stone-700 p-1.5 flex flex-row justify-between gap-8 w-full"> <div class="font-bold bg-stone-700 p-1.5 flex flex-row justify-between gap-8 w-full">
<div <div
class="flex flex-row flex-1 gap-3 pl-2 py-1 items-center grow whitespace-nowrap min-w-0 flex-shrink-0 text-lg {content.title_class ?? class="flex flex-row flex-1 gap-3 pl-2 py-1 items-center grow whitespace-nowrap min-w-0 shrink-0 text-lg {content.title_class ??
''}" ''}"
> >
{#if content.title_icon} {#if content.title_icon}
{@const Icon = content.title_icon} {@const Icon = content.title_icon}
<Icon strokeWidth="2" class="flex-shrink-0" /> <Icon strokeWidth="2" class="shrink-0" />
{/if} {/if}
<div class="flex-shrink-0"> <div class="shrink-0">
{content.title} {content.title}
</div> </div>
</div> </div>
<div class="flex aspect-square flex-shrink-0"> <div class="flex aspect-square shrink-0">
{#if content.closable} {#if content.closable}
<Button className="aspect-square !p-1.5" click_function={try_to_close}> <Button className="aspect-square p-1.5!" click_function={try_to_close}>
<X /> <X />
</Button> </Button>
{/if} {/if}
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { get_shifted_color } from '../ts/stores/ui_behavior'; import { get_shifted_color } from '$lib/ts/stores/ui_behavior';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { selected_display_ids } from '../ts/stores/select'; import { selected_display_ids } from '$lib/ts/stores/select';
let { let {
current_value = $bindable(), current_value = $bindable(),
@@ -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>
@@ -122,7 +122,7 @@
onkeydown={handle_keydown} onkeydown={handle_keydown}
oninput={validate_input} oninput={validate_input}
type="text" type="text"
class=" outline-none py-2 px-3 transition-all duration-100 flex-grow group" class=" outline-none py-2 px-3 transition-all duration-100 grow group"
{placeholder} {placeholder}
/> />
{#if extension} {#if extension}
-1
View File
@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
let {className = ''} = $props<{className?: string}>(); let { className = '' }: { className?: string } = $props();
</script> </script>
<svg <svg
+306
View File
@@ -0,0 +1,306 @@
import { notifications } from './stores/notification';
import {
to_display_status,
type DisplayStatus,
type Inode,
type RequestResponse,
type ShellCommandResponse,
type TreeElement
} from './types';
import { dev } from '$app/environment';
import { get_sanitized_file_url } from './utils';
export async function get_screenshot(ip: string): Promise<Blob | null> {
const options = { method: 'PATCH' };
const response = await request_display(ip, '/takeScreenshot', options);
if (!response.ok || !response.blob) return null;
return response.blob;
}
export async function open_file(ip: string, path_to_file: string): Promise<void> {
const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } };
await request_display(ip, get_sanitized_file_url(path_to_file), options);
}
export async function send_keyboard_input(ip: string, inputs: { key: string; action: 'press' | 'release'}[]): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
inputs
})
};
await request_display(ip, '/keyboardInput', options);
}
export async function show_html(ip: string, html: string) {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: html
})
};
await request_display(ip, '/showHTML', options);
}
export async function get_file_data(
ip: string,
path: string
): Promise<{ folder_element: Inode; date_created: Date }[] | null> {
interface FileInfo {
name: string;
type: string;
size: string;
created: string;
}
const command = `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do
typ=$(file -b --mime-type -- "$f")
size=$(stat -c '%s' -- "$f")
created=$(stat -c '%w' -- "$f")
[ "$created" = "-" ] && created=$(stat -c '%y' -- "$f")
jq -n --arg name "$f" --arg type "$typ" --arg size "$size" --arg created "$created" \
'{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n'
echo
done
`;
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (json_response.exitCode === 0 && json_response.stdout.trim() === '') return [];
if (handle_shell_error(ip, json_response, command, true)) return null;
const response: FileInfo[] = json_response.stdout
.trim()
.split('\n')
.filter(Boolean)
.map((line: string) => JSON.parse(line) as FileInfo);
const folder_element_list: { folder_element: Inode; date_created: Date }[] = [];
for (const response_element of response) {
// filter hidden files (start with '.' -> './.config')
if (response_element.name.charAt(2) === '.') continue;
const folder_element: Inode = {
path: path,
name: response_element.name.slice(2), // remove "./"
type: response_element.type,
size: Number(response_element.size),
thumbnail: null
};
folder_element_list.push({ folder_element, date_created: new Date(response_element.created) });
}
return folder_element_list;
}
export async function get_file_tree_data(ip: string, path: string): Promise<TreeElement[] | null> {
const command = `cd ".${path}" && tree -Js`;
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (handle_shell_error(ip, json_response, command, true)) return null;
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> {
let command = `cd ".${path}"`;
for (const part of folder_names) {
command += ` && mkdir "${part}" && cd "${part}/"`;
}
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function rename_file(
ip: string,
path: string,
old_file_name: string,
new_file_name: string
): Promise<void> {
const command: string = `cd ".${path}" && mv "${old_file_name}" "${new_file_name}"`;
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function delete_files(
ip: string,
current_path: string,
file_names: string[]
): Promise<void> {
let command: string = `cd ".${current_path}"`;
for (const file_name of file_names) {
command += ` && rm -r "${file_name}"`;
}
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function show_blackscreen(ip: string): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: '<p></p>'
})
};
await request_display(ip, '/showHTML', options);
}
export async function get_thumbnail_blob(ip: string, path_to_file: string): Promise<Blob | null> {
const raw_response = await request_display(
ip,
get_sanitized_file_url(path_to_file, true),
{ 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;
const status = raw_response.json.status;
if (typeof status === 'string') {
return to_display_status(status);
}
return null;
}
async function request_display(
ip: string,
api_route: string,
options: { method: string; headers?: Record<string, string>; body?: string },
supress_error_handling_http_codes: number[] = []
): Promise<RequestResponse> {
const url = `http://${ip}:1323/api${api_route}`;
return await request(url, options, supress_error_handling_http_codes);
}
async function request_control(
api_route: string,
options: { method: string; headers?: Record<string, string>; body?: string }
): Promise<RequestResponse> {
const url = `http://127.0.0.1:8080/api${api_route}`;
return await request(url, options);
}
async function request(
url: string,
options: { method: string; headers?: Record<string, string>; body?: string },
supress_error_handling_http_codes: number[] = []
): Promise<RequestResponse> {
try {
const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`;
if (dev) {
console.debug('Sending request: ', url + cache_buster, 'with', options.body ?? 'none');
}
const response = await fetch(url + cache_buster, options);
if (response.ok || supress_error_handling_http_codes.includes(response.status)) {
const contentType = response.headers.get('content-type') || '';
let request_response: RequestResponse;
if (!contentType.includes('application/json')) {
const blob: Blob = await response.blob();
request_response = { ok: response.ok, http_code: response.status, blob: blob };
} else {
const json: Record<string, unknown> = await response.json();
request_response = { ok: response.ok, http_code: response.status, json: json };
if (dev) {
console.debug(request_response);
}
}
return request_response;
}
let error_description = url;
if (response.headers.get('content-type')?.includes('application/json')) {
try {
const json: { description: string } = await response.json();
error_description += '\n' + json.description;
} catch {
error_description += '\nCould not parse error description';
}
}
notifications.push('error', `Fehler ${response.status} bei API-Anfrage`, error_description);
} catch (error: unknown) {
if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) {
if (dev) {
console.warn('Request failed - Is the targeted device online?');
}
} else {
console.error(url, error);
notifications.push('error', `Fataler Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
}
}
return { ok: false };
}
function handle_shell_error(
ip: string,
shell_response: ShellCommandResponse,
shell_command: string,
command_includs_cd: boolean
): boolean {
if (shell_response.exitCode !== 0) {
if (
command_includs_cd &&
shell_response.stderr &&
/bash: line \d+: cd: .+: No such file or directory/.test(shell_response.stderr)
) {
if (dev) {
console.debug('current file_path does not exist on display:', ip);
}
return true;
}
console.error(shell_response);
notifications.push(
'error',
`Fehler ${shell_response.exitCode} in API-Shell`,
`${ip}\n${shell_command}\nFehler: ${shell_response.stderr}`
);
return true;
}
if (shell_response.stdout.trim() === '') return true;
return false;
}
async function run_shell_command(ip: string, command: string): Promise<RequestResponse> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: command
})
};
return await request_display(ip, '/shellCommand', options);
}
export async function shutdown(ip: string): Promise<RequestResponse> {
return await run_shell_command(ip, 'shutdown -h now');
}
export async function startup(mac: string): Promise<RequestResponse> {
return await request_control(`/wakeOnLan`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mac_address: mac })
});
}
+47
View File
@@ -0,0 +1,47 @@
import Dexie, { type Table } from 'dexie';
import type { Display, DisplayGroup, FileOnDisplay, Inode } from './types';
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,
loading_data
`,
displays: `
id,
ip,
mac,
position,
preview,
group_id,
name,
status
`,
display_groups: `
id,
position
`
});
}
}
export const db = new FileDatabase();
@@ -0,0 +1,401 @@
import { db } from './database';
import { get_display_by_id } from './stores/displays';
import {
get_current_folder_elements,
get_file_by_id,
remove_all_files_without_display,
remove_file_from_display
} from './stores/files';
import { notifications } from './stores/notification';
import { generate_thumbnail } from './stores/thumbnails';
import {
get_file_primary_key,
type FileLoadingData,
type FileOnDisplay,
type FileTransferTask,
type Inode,
type ShortDisplay
} from './types';
import { get_sanitized_file_url, get_uuid, make_valid_name } from './utils';
let is_processing: boolean = false;
const tasks: FileTransferTask[] = [];
export async function add_upload(
file_list: FileList,
selected_display_ids: string[],
current_file_path: string
) {
if (file_list.length === 0) return console.warn('Upload canceled: no selected files');
const used_file_names: string[] = await (
await get_current_folder_elements(current_file_path, selected_display_ids)
).map((e) => e.name);
for (const file of file_list) {
const file_name = generate_valid_file_name(file.name, used_file_names);
used_file_names.push(file_name);
const db_file: Inode = {
path: current_file_path,
name: file_name,
size: file.size,
type: file.type,
thumbnail: null
};
const file_primary_key = get_file_primary_key(db_file);
await db.files.put(db_file);
const upload_task_promises = selected_display_ids.map(async (display_id) => {
const display_ip = (await get_display_by_id(display_id))?.ip;
if (!display_ip) return;
const db_file_on_display: FileOnDisplay = {
display_id,
file_primary_key,
date_created: new Date(),
loading_data: {
type: 'upload',
percentage: 0,
bytes_per_second: 0,
seconds_until_finish: -1
}
};
await db.files_on_display.put(db_file_on_display);
return {
data: {
type: 'upload' as const,
file
},
display: {
id: display_id,
ip: display_ip
},
path: current_file_path,
file_name: file_name,
file_primary_key,
bytes_total: file.size
};
});
const upload_tasks = (await Promise.all(upload_task_promises)).filter((e) => !!e);
tasks.push(...upload_tasks);
}
await start_task_processing();
}
export async function add_sync(selected_file_id: string, selected_display_ids: string[]) {
const file_data = await find_file_data_on_active_selected_display(
selected_file_id,
selected_display_ids
);
if (!file_data) return console.warn('Sync canceled: no file_data');
tasks.push({
data: {
type: 'sync',
destination_displays: file_data.short_displays_without_file
},
display: file_data.short_display_with_file,
path: file_data.file.path,
file_name: file_data.file.name,
file_primary_key: selected_file_id,
bytes_total: file_data.file.size
});
await db.files_on_display.update([file_data.short_display_with_file.id, selected_file_id], {
loading_data: {
type: 'sync_download',
percentage: 0,
bytes_per_second: 0,
seconds_until_finish: -1
}
});
const display_ids_without_file = file_data.short_displays_without_file.map((d) => d.id);
await db.files_on_display
.where('file_primary_key')
.equals(selected_file_id)
.filter((e) => display_ids_without_file.includes(e.display_id))
.modify({
loading_data: {
type: 'sync_upload',
percentage: 0,
bytes_per_second: 0,
seconds_until_finish: -1
}
});
await start_task_processing();
}
async function find_file_data_on_active_selected_display(
selected_file_id: string,
selected_display_ids: string[]
): Promise<{
file_on_display: FileOnDisplay;
file: Inode;
short_display_with_file: ShortDisplay;
short_displays_without_file: ShortDisplay[];
} | null> {
const active_selected_displays = await db.displays
.where('id')
.anyOf(selected_display_ids)
.filter((d) => d.status === 'app_online')
.toArray();
const active_selected_display_ids = active_selected_displays.map((d) => d.id);
const fods = await db.files_on_display
.where('file_primary_key')
.equals(selected_file_id)
.filter((e) => active_selected_display_ids.includes(e.display_id))
.toArray();
if (fods.length < 0) return null;
const fod_with_file = fods[0];
const display_ids_with_file = fods.map((f) => f.display_id);
const ip = active_selected_displays.find((e) => e.id === fod_with_file.display_id)?.ip;
if (!ip) return null;
const short_displays_without_file: ShortDisplay[] = selected_display_ids
.filter((d) => !display_ids_with_file.includes(d))
.map((id) => {
const ip = active_selected_displays.find((d) => d.id === id)?.ip;
if (!ip) return null;
return { id, ip };
})
.filter((sd) => !!sd);
const inode = await get_file_by_id(selected_file_id);
if (!inode) return null;
return {
file_on_display: fod_with_file,
file: inode,
short_display_with_file: { id: fod_with_file.display_id, ip },
short_displays_without_file
};
}
function generate_valid_file_name(original_file_name: string, used_file_names: string[]): string {
const regex = /\s\((\d{1,3})\)$/;
let name: string = make_valid_name(original_file_name);
while (used_file_names.includes(name)) {
const last_dot = name.lastIndexOf('.');
let name_without_extension = last_dot > 0 ? name.slice(0, last_dot) : name;
const extension = last_dot > 0 ? name.slice(last_dot) : '';
if (!regex.test(name_without_extension)) {
name_without_extension += ' (1)';
} else {
const match = name_without_extension.match(regex);
const current_number: number = match ? Number(match[1]) : 0;
name_without_extension = name_without_extension.replace(regex, ` (${current_number + 1})`);
}
name = name_without_extension + extension;
}
return name;
}
async function upload(task: FileTransferTask): Promise<void> {
const task_data = task.data;
if (task_data.type !== 'upload' || !task_data.file) return console.warn('Task cancelled: wrong task type:', task);
await upload_file_via_xhr(task, task.display, task_data.file);
// Generate Thumbnail if not done already
setTimeout(async () => {
const inode_element: Inode | undefined = await db.files.get(
JSON.parse(task.file_primary_key) as [string, string, number, string]
);
if (!!inode_element && inode_element.thumbnail === null) {
await generate_thumbnail(task.display.ip, task.path, inode_element);
}
}, 10);
}
export async function sync(task: FileTransferTask) {
if (task.data.type !== 'sync') return console.warn('Task cancelled: wrong task type:', task);
const hasOPFS =
typeof navigator !== 'undefined' &&
'storage' in navigator &&
'getDirectory' in navigator.storage;
if (!hasOPFS) {
return show_error(
task,
'OPFS (navigator.storage.getDirectory) nicht verfügbar bitte Chromium/Edge/Chrome nutzen.'
);
}
const temp_name = `sync-${get_uuid()}.tmp`;
try {
// 01 - download file to disk (in browser, not for user)
const url = `http://${task.display.ip}:1323/api${get_sanitized_file_url(task.path + task.file_name)}`;
const fetch_source = await fetch(url, { method: 'GET' });
if (!fetch_source.ok || !fetch_source.body)
return show_error(task, `HTTP ${fetch_source.status}`);
const dir = await navigator.storage.getDirectory();
const file_handle = await dir.getFileHandle(temp_name, { create: true });
const writable = await file_handle.createWritable();
const reader = fetch_source.body.getReader();
const start_time = new Date();
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
await update_current_loading_data('sync_download', task, value.byteLength, start_time);
await writable.write(value);
}
await writable.close();
// 02 - send downloaded file to every destination_display
const temp_file = await file_handle.getFile();
for (const current_short_display of task.data.destination_displays) {
await upload_file_via_xhr(task, current_short_display, temp_file);
}
await dir.removeEntry(temp_name);
} catch (e) {
show_error(task, String(e));
}
}
export async function download_file(selected_file_id: string, selected_display_ids: string[]) {
const file_data = await find_file_data_on_active_selected_display(
selected_file_id,
selected_display_ids
);
if (!file_data || file_data.file.type === 'inode/directory') return console.warn('Download cancelled: is folder');
try {
const url = `http://${file_data.short_display_with_file.ip}:1323/api${get_sanitized_file_url(file_data.file.path + file_data.file.name)}`;
const a = document.createElement('a');
a.href = url;
a.download = file_data.file.name; // Hint für den Browser (Server-Header ist trotzdem besser)
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
} catch (e) {
notifications.push(
'error',
'Fehler beim Dateidownload',
`Datei: "${file_data.file.name}", Display-IP: ${file_data.short_display_with_file.ip}\nFehler: ${e}`
);
}
}
async function start_task_processing() {
if (!is_processing) {
is_processing = tasks.length !== 0;
await start_task_loop();
}
}
async function start_task_loop() {
while (tasks.length > 0) {
console.log('NOCH', tasks.length, 'ZU TUN');
const current_task = tasks[0];
if (current_task.data.type === 'upload') {
await upload(current_task);
}
else if (current_task.data.type === 'sync') {
await sync(current_task);
}
tasks.shift(); // Remove current_task from tasks
}
is_processing = false;
}
async function upload_file_via_xhr(task: FileTransferTask, current_short_display: ShortDisplay, current_file: File) {
const start_time = new Date();
return new Promise<void>((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open(
'POST',
`http://${current_short_display.ip}:1323/api${get_sanitized_file_url(task.path + task.file_name)}`,
true
);
xhr.setRequestHeader('content-type', 'application/octet-stream');
xhr.upload.onprogress = (e) => {
const apply = async () => {
await update_current_loading_data(task.data.type === 'upload' ? 'upload' : 'sync_upload', task, e.loaded, start_time, current_short_display.id);
};
apply();
};
xhr.onerror = async (e: ProgressEvent) => {
await show_error(task, e);
resolve();
};
xhr.onreadystatechange = async () => {
if (xhr.readyState === 4) {
if (xhr.status == 200) {
await db.files_on_display.update([current_short_display.ip, task.file_primary_key], {
loading_data: null
}); // TODO: Stattdessen update machen und gucken, ob die Datei wirklich da ist
} else {
await show_error(task, `HTTP ${xhr.status}`);
}
resolve();
}
};
xhr.send(current_file);
})
}
async function update_current_loading_data(type: FileLoadingData['type'], task: FileTransferTask, current_bytes: number, start_time: Date, other_display_id: string | null = null) {
const current_percentage = task.bytes_total > 0 ? Math.round((current_bytes / task.bytes_total) * 100) : 1;
const prognosed_data = get_prognosed_data(start_time, current_bytes, task.bytes_total);
await db.files_on_display.update([other_display_id ? other_display_id : task.display.id, task.file_primary_key], {
loading_data: {
type,
percentage: current_percentage,
bytes_per_second: prognosed_data.bytes_per_second,
seconds_until_finish: prognosed_data.seconds_until_finish
}
});
}
function get_prognosed_data(
start_time: Date,
bytes_done: number,
bytes_total: number
): { bytes_per_second: number; seconds_until_finish: number } {
const diff_seconds = (Date.now() - start_time.getTime()) / 1000;
const bytes_per_second = bytes_done / diff_seconds;
const seconds_until_finish = bytes_total / bytes_per_second;
return { bytes_per_second, seconds_until_finish };
}
async function show_error(task: FileTransferTask, error: ProgressEvent | string) {
const task_data = task.data;
console.error('Error in File-Transfer-Handler:', error, task);
notifications.push(
'error',
`Fehler beim ${task_data.type === 'upload' ? 'Dateiupload' : 'Sychronisieren von Dateien'}`,
`Datei: "${task.file_name}", Display-IP: ${task.display.ip}\nFehler: ${error}`
);
if (task_data.type === 'upload') {
await remove_file_from_display(task.display.id, task.file_primary_key);
await remove_all_files_without_display();
} else if (task_data.type === 'sync') {
for (const display_id of task_data.destination_displays.map((e) => e.id)) {
await remove_file_from_display(display_id, task.file_primary_key);
}
await remove_all_files_without_display();
}
}
+34
View File
@@ -0,0 +1,34 @@
import { screenshot_loop } from './stores/displays';
import { ping_ip } from './api_handler';
import type { Display } from './types';
import { update_folder_elements_recursively } from './stores/files';
import { db } from './database';
const update_display_status_interval_seconds = 20;
export async function on_start() {
await db.files.clear();
await db.files_on_display.clear();
await update_all_display_status();
await setInterval(update_all_display_status, update_display_status_interval_seconds * 1000);
}
async function update_all_display_status() {
const all_displays: Display[] = await db.displays.toArray();
for (const display of all_displays) {
const new_status = await ping_ip(display.ip);
if (new_status === null && display.status !== null) continue;
if (new_status === 'app_online' && display.status !== 'app_online') {
on_display_start(display);
}
if (new_status !== display.status) {
display.status = new_status;
await db.displays.put(display); // save
}
}
}
async function on_display_start(display: Display) {
await update_folder_elements_recursively(display, '/');
await screenshot_loop(display.id);
}
+197
View File
@@ -0,0 +1,197 @@
import { get } from 'svelte/store';
import type { Display, DisplayGroup, DisplayStatus } from '../types';
import { is_selected, select, selected_display_ids } from './select';
import { get_uuid, image_content_hash } from '../utils';
import { get_screenshot } from '../api_handler';
import { delete_and_deselect_unique_files_from_display } from './files';
import { db } from '../database';
export async function is_display_name_taken(name: string): Promise<boolean> {
const exists = await db.displays.where('name').equals(name).first();
return !!exists;
}
export async function add_display(
ip: string,
mac: string | null,
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;
} else {
group_id = get_uuid();
await db.display_groups.put({ id: group_id, position: 0 });
}
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
) {
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 async function remove_display(display_id: string) {
select(selected_display_ids, display_id, 'deselect');
await delete_and_deselect_unique_files_from_display(display_id);
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 async function all_displays_of_group_selected(
display_group_id: string,
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)) {
return false;
}
}
return true;
}
export async function set_select_for_group(display_group_id: string, new_value: boolean) {
const displays_of_group: Display[] = await db.displays
.where('group_id')
.equals(display_group_id)
.toArray();
for (const display of displays_of_group) {
let action: string;
if (new_value === true) {
action = 'select';
} else {
action = 'deselect';
}
select(selected_display_ids, display.id, action as 'toggle' | 'select' | 'deselect');
}
}
export async function get_display_by_id(display_id: string): Promise<Display | null> {
return (await db.displays.get(display_id)) ?? null;
}
export async function screenshot_loop(display_id: string, initial_retry_count: number = 5) {
const display = await db.displays.get(display_id);
if (!display || display.preview.currently_updating) return;
display.preview.currently_updating = true;
await db.displays.update(display.id, { preview: display.preview });
let last_hash: number | null = null;
let retry_count = initial_retry_count;
while (retry_count > 0) {
retry_count -= 1;
const new_blob = await get_screenshot(display.ip);
if (!new_blob) {
display.preview = { currently_updating: false, url: null };
await db.displays.update(display.id, { preview: display.preview });
return;
}
const new_hash = await image_content_hash(new_blob);
if (last_hash !== new_hash) {
if (display.preview.url) {
URL.revokeObjectURL(display.preview.url);
}
last_hash = new_hash;
display.preview.url = URL.createObjectURL(new_blob);
await db.displays.update(display.id, { preview: display.preview });
retry_count = initial_retry_count;
}
await new Promise((resolve) => setTimeout(resolve, 2000)); // sleep 2s
}
display.preview.currently_updating = false;
await db.displays.update(display.id, { preview: display.preview });
}
export async function run_on_all_selected_displays(
run_function: (display: Display) => void | Promise<void>,
update_screenshot_afterwards: boolean = true,
ignore_offline: boolean = true
) {
const maybe_displays: (Display | null)[] = await Promise.all(
// fails when only a single promis fails
get(selected_display_ids).map(async (id) => await get_display_by_id(id))
);
const displays: Display[] = maybe_displays.filter((d): d is Display => d !== null);
Promise.all(
displays.map(async (display) => {
if (!display || (ignore_offline && display.status === 'host_offline')) return;
await run_function(display);
if (update_screenshot_afterwards) {
await screenshot_loop(display.id);
}
})
);
}
export async function get_display_groups(): Promise<DisplayGroup[]> {
return await db.display_groups.orderBy('position').toArray();
}
export async function get_display_ids_in_group(display_group_id: string): Promise<Display[]> {
const displays: Display[] = await db.displays
.where('group_id')
.equals(display_group_id)
.sortBy('position');
return displays;
}
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");
}
+410
View File
@@ -0,0 +1,410 @@
import { get, writable, type Writable } from 'svelte/store';
import {
get_file_primary_key,
type Display,
type FileOnDisplay,
type Inode,
type TreeElement
} from '../types';
import { get_display_by_id } from './displays';
import { is_selected, select, selected_display_ids, selected_file_ids } from './select';
import { create_folders, get_file_data, get_file_tree_data } from '../api_handler';
import { deactivate_old_thumbnail_urls, generate_thumbnail } from './thumbnails';
import { db } from '../database';
export const current_file_path: Writable<string> = writable<string>('/');
export async function change_file_path(new_path: string) {
current_file_path.update(() => {
return new_path;
});
selected_file_ids.update(() => {
return [];
});
deactivate_old_thumbnail_urls();
const displays = await db.displays.toArray();
for (const display of displays) {
const changed_paths = await get_changed_directory_paths(display, new_path);
if (!changed_paths) continue;
console.debug('Update file system from', display.name, ':', changed_paths);
for (const path of changed_paths) {
await update_folder_elements_recursively(display, path);
}
}
}
export async function delete_and_deselect_unique_files_from_display(display_id: string) {
const files_on_display = await db.files_on_display
.where('display_id')
.equals(display_id)
.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();
}
export 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]);
}
export 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() {
selected_file_ids.update(() => {
return [];
});
const current_path = get(current_file_path);
for (const display of await db.displays.where('id').anyOf(get(selected_display_ids)).toArray()) {
await update_folder_elements_recursively(display, current_path);
}
}
export async function get_missing_colliding_display_ids(
file: Inode,
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_files = await db.files
.where('[path+name]')
.equals([file.path, file.name])
.filter((e) => e.size !== file.size || e.type !== file.type)
.toArray();
for (const colliding_file of colliding_files) {
colliding.push(
...(await get_display_ids_where_file_is_missing(colliding_file, selected_display_ids))
);
}
return { missing, colliding };
}
async function get_display_ids_where_file_is_missing(
file: Inode,
selected_display_ids: string[]
): Promise<string[]> {
const file_primary_key = get_file_primary_key(file);
const files_on_selected_displays = await db.files_on_display
.where('file_primary_key')
.equals(file_primary_key)
.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)
);
}
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 directory_strings = await get_recursive_changed_directory_paths(
display,
file_path,
current_folder
);
if (directory_strings.size === 0) return null;
const directory_strings_array = [...directory_strings];
return directory_strings_array.filter(
(e) => !directory_strings_array.some((f) => f !== e && f.startsWith(e))
);
}
async function get_recursive_changed_directory_paths(
display: Display,
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
} else if (
!files_folder ||
!current_folder_elements ||
current_folder_elements.length !== files_folder.length
) {
return new Set([current_file_path]); // existing data does not match new data -> update
}
const has_changed: Set<string> = new Set();
for (const tree_folder_element of current_folder_elements) {
const folder_element = files_folder.find((e) => e.name === tree_folder_element.name);
if (
!folder_element ||
(tree_folder_element.type !== 'directory' && folder_element.size !== tree_folder_element.size)
) {
return new Set([current_file_path]);
}
if (tree_folder_element.type === 'directory' && tree_folder_element.contents) {
const new_file_path = current_file_path + tree_folder_element.name + '/';
for (const string of await get_recursive_changed_directory_paths(
display,
new_file_path,
tree_folder_element.contents
)) {
has_changed.add(string);
}
}
}
return has_changed;
}
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);
if (!new_folder_elements) return;
const existing_file_keys_on_display_in_path: [string, string, number, string][] = (
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),
loading_data: null,
date_created: new_element.date_created
};
await db.files_on_display.put(file_on_display);
if (new_element.folder_element.type === 'inode/directory') {
await update_folder_elements_recursively(
display,
file_path + new_element.folder_element.name + '/'
);
}
}
// Generate Thumbnails:
setTimeout(async () => {
for (const new_element of diff.new) {
await generate_thumbnail(display.ip, file_path, new_element.folder_element);
}
}, 0);
}
if (diff.deleted.length > 0) {
// Remove old Folder-Elements
for (const old_element of diff.deleted) {
remove_file_from_display(display.id, get_file_primary_key(old_element));
}
await remove_all_files_without_display();
}
}
function get_folder_elements_difference(
old_elements: Inode[],
new_elements: { folder_element: Inode; date_created: Date }[]
): { deleted: Inode[]; new: { folder_element: Inode; date_created: Date }[] } {
const old_keys = new Set(old_elements.map((e) => get_file_primary_key(e)));
const new_keys = new Set(new_elements.map((e) => get_file_primary_key(e.folder_element)));
const only_in_old = old_elements.filter((e) => !new_keys.has(get_file_primary_key(e)));
const only_in_new = new_elements.filter(
(e) => !old_keys.has(get_file_primary_key(e.folder_element))
);
return { deleted: only_in_old, new: only_in_new };
}
export async function get_current_folder_elements(
current_file_path: string,
selected_display_ids: string[]
): Promise<Inode[]> {
const existing_file_keys_on_selected_displays: [string, string, number, string][] = (
await db.files_on_display.where('display_id').anyOf(selected_display_ids).toArray()
).map((e) => JSON.parse(e.file_primary_key) as [string, string, number, string]);
const existing_files_on_selected_displays_in_path: Inode[] = await db.files
.where('[path+name+size+type]')
.anyOf(existing_file_keys_on_selected_displays)
.filter((e) => e.path === current_file_path)
.toArray();
return sort_files(existing_files_on_selected_displays_in_path);
}
function sort_files(files: Inode[]) {
files.sort((a, b) => {
const isDirA = a.type === 'inode/directory';
const isDirB = b.type === 'inode/directory';
// Ordner zuerst
if (isDirA && !isDirB) return -1;
if (!isDirA && isDirB) return 1;
// Danach alphabetisch nach name (case-insensitive)
const nameCompare = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
if (nameCompare !== 0) return nameCompare;
return -1;
});
return files;
}
export async function get_file_by_id(
file_primary_key: string,
only_from_selected_displays: boolean = false
): Promise<Inode | null> {
const file = (await db.files.get(JSON.parse(file_primary_key))) ?? null;
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;
}
}
export async function run_for_selected_files_on_selected_displays(
action: (ip: string, file_names: string[]) => Promise<void>
): Promise<void> {
for (const display_id of get(selected_display_ids)) {
const file_key_strings_on_display: string[] = (
await db.files_on_display.where('display_id').equals(display_id).toArray()
).map((e) => e.file_primary_key);
const selected_file_keys_on_display: [string, string, number, string][] =
file_key_strings_on_display
.filter((primary_key_string) => is_selected(primary_key_string, get(selected_file_ids)))
.map(
(primary_key_string) => JSON.parse(primary_key_string) as [string, string, number, string]
);
if (selected_file_keys_on_display.length === 0) continue;
const selected_file_names_on_display: string[] = (
await db.files.where('[path+name+size+type]').anyOf(selected_file_keys_on_display).toArray()
).map((e) => e.name);
const display = await get_display_by_id(display_id);
if (!display) continue;
await action(display.ip, selected_file_names_on_display);
}
}
export async function create_folder_on_all_selected_displays(
folder_name: string,
path: string,
selected_display_ids: string[]
): Promise<void> {
const path_parts = path
.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);
}
}
}
export async function get_date_mapping(file_primary_key: string): Promise<Record<string, Date>> {
const same_file_on_displays: FileOnDisplay[] = await db.files_on_display
.where('file_primary_key')
.equals(file_primary_key)
.toArray();
const displays_with_that_file: Display[] = await db.displays
.where('id')
.anyOf(same_file_on_displays.map((e) => e.display_id))
.toArray();
const out: Record<string, Date> = {};
for (const current_file_on_display of same_file_on_displays) {
const current_name = displays_with_that_file.find(
(e) => e.id === current_file_on_display.display_id
)?.name;
if (!current_name) continue;
const current_date = current_file_on_display.date_created;
out[current_name] = current_date;
}
return out;
}
@@ -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;
+26
View File
@@ -0,0 +1,26 @@
import { writable, type Writable } from 'svelte/store';
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 function select(
selected_ids: Writable<string[]>,
id: string,
action: 'toggle' | 'select' | 'deselect'
) {
selected_ids.update((all_ids: string[]) => {
if (all_ids.includes(id)) {
const index = all_ids.indexOf(id);
if (index > -1 && action !== 'select') {
all_ids.splice(index, 1);
}
} else if (action !== 'deselect') {
all_ids.push(id);
}
return all_ids;
});
}
export function is_selected(id: string, selected_ids: string[]): boolean {
return selected_ids.includes(id);
}
+45
View File
@@ -0,0 +1,45 @@
import { get, writable, type Writable } from 'svelte/store';
import { get_thumbnail_blob } from '../api_handler';
import { type Inode } from '../types';
import { db } from '../database';
import { get_file_type } from '../utils';
import { get_file_by_id } from './files';
export const active_thumbnail_urls: Writable<string[]> = writable<string[]>([]);
export async function generate_thumbnail(
display_ip: string,
path: string,
folder_element: Inode
): Promise<void> {
const supported_file_type = get_file_type(folder_element);
if (!supported_file_type) return;
const thumbnail_blob = await get_thumbnail_blob(display_ip, path + folder_element.name);
if (!thumbnail_blob) return;
folder_element.thumbnail = thumbnail_blob;
await db.files.put(folder_element); // save
}
export async function get_thumbnail_url(file_primary_key: string): Promise<string | null> {
const file: Inode | null = await get_file_by_id(file_primary_key);
if (!file || !file.thumbnail) return null;
const new_url = URL.createObjectURL(file.thumbnail);
active_thumbnail_urls.update((current: string[]) => {
current.push(new_url);
return current;
});
return new_url;
}
export function deactivate_old_thumbnail_urls() {
const current_urls = get(active_thumbnail_urls);
for (const url of current_urls) {
URL.revokeObjectURL(url);
}
active_thumbnail_urls.update(() => {
return [];
});
}
+134
View File
@@ -0,0 +1,134 @@
import { FileBox, FileImage, FileText, FileVideoCamera, ImagePlay, type X } from 'lucide-svelte';
import type { Snippet } from 'svelte';
export type RequestResponse = {
ok: boolean;
http_code?: number;
blob?: Blob;
json?: Record<string, unknown>;
};
export type ShellCommandResponse = {
stdout: string;
stderr: string;
exitCode: number;
};
export type SupportedFileType = {
display_name: string;
mime_type: string;
};
export const supported_file_type_icon: Record<string, typeof X> = {
MP4: FileVideoCamera,
JPG: FileImage,
PNG: FileImage,
GIF: ImagePlay,
PPTX: FileBox,
ODP: FileBox,
PDF: FileText
};
export type FileTransferTask = {
data: FileTransferTaskData;
display: ShortDisplay;
path: string;
file_name: string;
file_primary_key: string;
bytes_total: number; // if type === 'sync' -> bytes_total = file_size * 2 (1x download + 1x upload)
};
export type FileTransferTaskData = {
type: 'upload',
file: File,
} | {
type: "sync",
destination_displays: ShortDisplay[],
}
export type ShortDisplay = {
id: string;
ip: string;
}
export type FileOnDisplay = {
display_id: string;
file_primary_key: string; // JSON.stringify([string, string, number, string])
date_created: Date;
loading_data: FileLoadingData | null; // null if not loading
};
export type FileLoadingData = {
type: 'upload' | 'download' | 'sync_download' | 'sync_upload';
percentage: number;
bytes_per_second: number;
seconds_until_finish: number;
}
export type Inode = {
path: string;
name: string;
size: number;
type: string;
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 = {
contents?: TreeElement[];
type: 'file' | 'directory';
name: string;
size: number;
};
export type Display = {
id: string;
ip: string;
mac: string | null;
position: number;
preview: PreviewObject;
group_id: string;
name: string;
status: DisplayStatus;
};
export type DisplayGroup = {
id: string;
position: number;
};
export type PreviewObject = {
currently_updating: boolean;
url: string | null;
};
export type MenuOption = {
icon?: typeof X;
name: string;
class?: string;
on_select?: () => void | Promise<void>;
disabled?: boolean;
};
export type PopupContent = {
open: boolean;
snippet: Snippet<[string]> | null;
snippet_arg?: string;
title?: string;
title_class?: string;
title_icon?: typeof X | null;
window_class?: string;
closable?: boolean;
};
export type DisplayStatus = 'host_offline' | 'app_offline' | 'app_online' | null;
export function to_display_status(value: string): DisplayStatus {
return ['host_offline', 'app_offline', 'app_online'].includes(value)
? (value as DisplayStatus)
: null;
}
+115
View File
@@ -0,0 +1,115 @@
import type { DisplayStatus, Inode, SupportedFileType } from './types';
import supported_file_types_json from './../../../../../shared/supported_file_types.json';
const supported_file_types: Record<string, SupportedFileType> = supported_file_types_json as Record<
string,
SupportedFileType
>;
const invalid_first_regex = /^[^a-zA-ZäöüßÄÖÜ0-9_]/u;
const invalid_rest_regex = /[^a-zA-ZäöüßÄÖÜ0-9 _\-()$${}.,+$!=/]/gu;
export function is_valid_name(input: string): boolean {
return !invalid_rest_regex.test(input) && first_letter_is_valid(input);
}
export function first_letter_is_valid(input: string): boolean {
if (input.length === 0) return false;
return !invalid_first_regex.test(input);
}
export function make_valid_name(input: string): string {
const s = input.normalize('NFC');
if (s.length === 0) return 'Datei';
let out = s.replace(invalid_rest_regex, '_');
out = out.replace(invalid_first_regex, '_');
return out;
}
export function get_file_type(file: Inode): SupportedFileType | null {
for (const key of Object.keys(supported_file_types)) {
if (file.type === supported_file_types[key].mime_type) {
return supported_file_types[key];
}
}
// Fallback:
const extension = file.name.split('.').pop();
if (extension) {
if (Object.keys(supported_file_types).includes('.' + extension)) {
return supported_file_types['.' + extension];
}
}
return null;
}
export function get_accepted_file_type_string(): string {
return Object.values(supported_file_types)
.map((e) => e.mime_type)
.join(',');
}
export function get_uuid(): string {
return crypto.randomUUID();
}
export function get_file_size_display_string(size: number, toFixed: number | null = null): string {
if (size < 0)
return toFixed === null ? 'versch.' : 'Verschiedene Größen auf verschiedenen Bildschirmen';
if (size === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(size) / Math.log(k));
const value = size / Math.pow(k, i);
const size_string = `${value.toFixed(toFixed !== null ? toFixed : Math.max(0, 2 - Math.floor(Math.log10(value))))} ${sizes[i]}`;
return size_string.replace('.', ',');
}
export async function image_content_hash(blob: Blob, size = 32): Promise<number> {
// Blob → ImageBitmap (GPU-dekodiert, superschnell)
const bitmap = await createImageBitmap(blob);
// OffscreenCanvas ist sehr schnell (kein Layout nötig)
const canvas = new OffscreenCanvas(size, size);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0, size, size);
// Pixel-Daten holen
const { data } = ctx.getImageData(0, 0, size, size);
// Einfacher, schneller Integer-Hash (FNV-1a)
let hash = 2166136261;
for (let i = 0; i < data.length; i++) {
hash ^= data[i];
hash = Math.imul(hash, 16777619);
}
bitmap.close(); // GPU-Ressourcen freigeben
return hash >>> 0; // unsigned int
}
export function display_status_to_info(status: DisplayStatus): string {
switch (status) {
case 'app_online':
return 'Online';
case 'app_offline':
return 'Lädt';
case 'host_offline':
return 'Offline';
case null:
return '???';
}
}
export function get_sanitized_file_url(file_path: string, is_preview = false) {
const pathSegments = file_path
.split('/')
.filter(Boolean)
.map((seg) => encodeURIComponent(seg));
return `/file/${is_preview ? 'preview/' : ''}${[...pathSegments].join('/')}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import Notification from '../components/Notification.svelte'; import Notification from './Notification.svelte';
let { children } = $props(); let { children } = $props();
</script> </script>
+1
View File
@@ -1 +1,2 @@
export const prerender = true; export const prerender = true;
export const ssr = false;
+36 -41
View File
@@ -1,28 +1,25 @@
<script lang="ts"> <script lang="ts">
import { Monitor, Plus, Radio, Settings, Trash2, X } from 'lucide-svelte'; import { Monitor, Plus, Radio, Settings, Trash2, X } from 'lucide-svelte';
import Button from '../components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import FileView from '../components/FileView.svelte'; import FileView from './FileView.svelte';
import ControlView from '../components/ControlView.svelte'; import ControlView from './ControlView.svelte';
import DisplayView from '../components/DisplayView.svelte'; import DisplayView from './DisplayView.svelte';
import SplashScreen from './../../../../shared/splash_screen.html?raw'; import PopUp from '$lib/components/PopUp.svelte';
import PopUp from '../components/PopUp.svelte'; import { type PopupContent } from '$lib/ts/types';
import { type PopupContent } from '../ts/types'; import TextInput from '$lib/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,
remove_display remove_display
} from '../ts/stores/displays'; } from '$lib/ts/stores/displays';
import { text } from '@sveltejs/kit'; import { notifications } from '$lib/ts/stores/notification';
import { notifications } from '../ts/stores/notification'; import { ping_ip } from '$lib/ts/api_handler';
import { ping_ip } from '../ts/api_handler';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { on_start } from '../ts/main'; import { on_start } from '$lib/ts/main';
import { display_status_to_info } from '../ts/utils'; import { display_status_to_info } from '$lib/ts/utils';
import HighlightedText from '../components/HighlightedText.svelte'; import HighlightedText from '$lib/components/HighlightedText.svelte';
const ip_regex = const ip_regex =
/^(?:(?:10|127)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)|192\.168\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)|172\.(?:1[6-9]|2\d|3[0-1])\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d))$/; /^(?:(?:10|127)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)|192\.168\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)|172\.(?:1[6-9]|2\d|3[0-1])\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d))$/;
@@ -35,6 +32,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: '' },
@@ -53,6 +51,7 @@
} }
async function finalize_add_edit_display(existing_display_id: string | null) { async function finalize_add_edit_display(existing_display_id: string | null) {
popup_close_function();
const ip = text_inputs_valid.ip.value; const ip = text_inputs_valid.ip.value;
const mac = text_inputs_valid.mac.value === '' ? null : text_inputs_valid.mac.value; const mac = text_inputs_valid.mac.value === '' ? null : text_inputs_valid.mac.value;
const name = text_inputs_valid.name.value; const name = text_inputs_valid.name.value;
@@ -62,7 +61,6 @@
const status = await ping_ip(text_inputs_valid.ip.value); const status = await ping_ip(text_inputs_valid.ip.value);
await add_display(ip, mac, name, status); await add_display(ip, mac, name, status);
} }
popup_close_function();
} }
function popup_close_function() { function popup_close_function() {
@@ -81,7 +79,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 +92,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,27 +111,24 @@
}; };
}; };
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="button space font-bold" click_function={popup_close_function}>
Abbrechen
</Button>
<Button <Button
hover_bg="bg-red-400" className="error button space"
active_bg="bg-red-500" click_function={async () => {
className="px-4 flex text-red-400 hover:text-stone-100"
click_function={() => {
remove_display(display_id);
popup_close_function(); popup_close_function();
await remove_display(display_id);
}}>Löschen</Button }}>Löschen</Button
> >
</div> </div>
@@ -145,13 +141,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 +195,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 +208,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
@@ -231,7 +227,6 @@
<span class="text-4xl font-bold content-center pl-1"> PLG MuDiCS </span> <span class="text-4xl font-bold content-center pl-1"> PLG MuDiCS </span>
<Button <Button
className="aspect-square" className="aspect-square"
bg="bg-stone-800"
div_class="aspect-square" div_class="aspect-square"
menu_options={[ menu_options={[
{ {
@@ -11,15 +11,15 @@
TextAlignStart, TextAlignStart,
TrafficCone TrafficCone
} from 'lucide-svelte'; } from 'lucide-svelte';
import Button from './Button.svelte'; import Button from '$lib/components/Button.svelte';
import PopUp from './PopUp.svelte'; import PopUp from '$lib/components/PopUp.svelte';
import type { PopupContent } from '../ts/types'; import type { PopupContent } from '$lib/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, shutdown, startup } from '$lib/ts/api_handler';
import { run_on_all_selected_displays } from '../ts/stores/displays'; import { get_display_by_id, run_on_all_selected_displays } from '$lib/ts/stores/displays';
import { selected_display_ids } from '../ts/stores/select'; import { selected_display_ids } from '$lib/ts/stores/select';
import { onMount } from 'svelte';
import TipTapInput from './TipTapInput.svelte'; import TipTapInput from './TipTapInput.svelte';
import { db } from '$lib/ts/database';
let popup_content: PopupContent = $state({ let popup_content: PopupContent = $state({
open: false, open: false,
@@ -52,8 +52,76 @@
window_class: 'size-full' window_class: 'size-full'
}; };
}; };
async function all_state(selected_display_ids: string[]): Promise<'on' | 'off' | 'mixed'> {
const selected_displays = await Promise.all(
selected_display_ids.map(async (id) => await get_display_by_id(id))
);
const selected_display_states = selected_displays.map((display) => display?.status ?? '');
if (selected_display_states.every((state) => state === 'app_online')) {
return 'on';
} else if (selected_display_states.every((state) => state !== 'app_online')) {
return 'off';
} else {
return 'mixed';
}
}
async function ask_shutdonw() {
popup_content = {
open: true,
snippet: ask_shutdonw_popup,
title: 'PC Herunterfahren',
title_icon: PowerOff,
closable: true
};
}
async function shutdown_action() {
popup_content.open = false;
await run_on_all_selected_displays((d) => {
shutdown(d.ip); // no await here because we want to be fast
db.displays.update(d.id, { status: 'app_offline' });
}, false);
}
async function startup_action() {
await run_on_all_selected_displays(
async (d) => {
if (!d.mac) return;
startup(d.mac); // no await here because we want to be fast
db.displays.update(d.id, { status: 'app_offline' });
},
false,
false
);
}
async function send_single_key_press(key: string) {
await run_on_all_selected_displays((d) =>
send_keyboard_input(d.ip, [{ key, action: 'press' }])
);
setTimeout(
async () =>
await run_on_all_selected_displays((d) =>
send_keyboard_input(d.ip, [{ key, action: 'release' }])
),
10
);
}
</script> </script>
{#snippet ask_shutdonw_popup()}
<p>Bist du sicher, dass du alle ausgewählten Displays herunterfahren möchtest?</p>
<div class="flex flex-row justify-end gap-2">
<Button className="button space" click_function={() => (popup_content.open = false)}>
Abbrechen
</Button>
<Button click_function={shutdown_action} className="button error space">Herunterfahren</Button>
</div>
{/snippet}
{#snippet send_keys_popup()} {#snippet send_keys_popup()}
<div class="overflow-hidden flex flex-col gap-2"> <div class="overflow-hidden flex flex-col gap-2">
<div> <div>
@@ -81,16 +149,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 send_single_key_press('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 send_single_key_press('VK_RIGHT');
}}><ArrowBigRight /></Button }}><ArrowBigRight /></Button
> >
</div> </div>
@@ -102,8 +170,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((d) => show_blackscreen(d.ip));
}}><Presentation />Blackout</Button }}><Presentation />Blackout</Button
> >
<div class="flex flex-row justify-normal"> <div class="flex flex-row justify-normal">
@@ -122,14 +190,23 @@
</div> </div>
<div class="flex flex-col gap-2 justify-between"> <div class="flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#await all_state($selected_display_ids) then all}
<Button <Button
className="px-3 flex gap-3 w-full xl:w-75 justify-normal" className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
disabled={$selected_display_ids.length === 0}><Power /> PC hochfahren</Button disabled={all === 'on' || $selected_display_ids.length === 0}
click_function={startup_action}
> >
<Power /> PC hochfahren
</Button>
<Button <Button
className="px-3 flex gap-3 w-full xl:w-75 justify-normal" className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
disabled={$selected_display_ids.length === 0}><PowerOff /> PC herunterfahren</Button disabled={all === 'off' || $selected_display_ids.length === 0}
click_function={ask_shutdonw}
> >
<PowerOff /> PC herunterfahren</Button
>
{/await}
</div> </div>
<Button <Button
className="px-3 flex gap-3 w-full xl:w-75 justify-normal" className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
@@ -2,41 +2,54 @@
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,
} from '../ts/stores/displays'; set_select_for_group,
set_new_display_group_order
} from '$lib/ts/stores/displays';
import { import {
change_height, change_height,
current_height, current_height,
dnd_flip_duration_ms, dnd_flip_duration_ms,
get_selectable_color_classes,
is_display_drag, is_display_drag,
is_group_drag, is_group_drag,
next_height_step_size, next_height_step_size,
pinned_display_id pinned_display_id
} from '../ts/stores/ui_behavior'; } from '$lib/ts/stores/ui_behavior';
import { type Display, type DisplayGroup, type MenuOption } from '../ts/types'; import { type Display, type DisplayGroup, type MenuOption } from '$lib/ts/types';
import Button from './Button.svelte'; import Button from '$lib/components/Button.svelte';
import OnlineState from './OnlineState.svelte'; import OnlineState from '../lib/components/OnlineState.svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { Menu, Minus, Pencil, PinOff, Plus, Settings, Trash2, VideoOff } from 'lucide-svelte'; import { Menu, Pencil, PinOff, Settings, Trash2, VideoOff, ZoomIn, ZoomOut } from 'lucide-svelte';
import { selected_display_ids } from '../ts/stores/select'; import { selected_display_ids } from '$lib/ts/stores/select';
import { dragHandleZone } from 'svelte-dnd-action'; import { dragHandleZone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import DisplayGroupObject from './DisplayGroupObject.svelte'; import DisplayGroupObject from '../lib/components/DisplayGroupObject.svelte';
import { Pane, Splitpanes } from 'svelte-splitpanes'; import { Pane, Splitpanes } from 'svelte-splitpanes';
import HighlightedText from './HighlightedText.svelte'; import HighlightedText from '$lib/components/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> | undefined = $state();
get_display_by_id($pinned_display_id || '', $displays) $effect(() => {
); const pdi = $pinned_display_id;
pinned_display = liveQuery(() => get_display_by_id(pdi || ''));
});
let display_groups = liveQuery(() => get_display_groups());
let all_groups_selected: Observable<boolean> | undefined = $state();
$effect(() => {
const d = $display_groups;
const sdi = $selected_display_ids;
all_groups_selected = liveQuery(() => all_selected(d || [], sdi));
});
let last_pinned_pane_size: number = 45; let last_pinned_pane_size: number = 45;
let pinned_pane_size: number = $state(last_pinned_pane_size); let pinned_pane_size: number = $state(last_pinned_pane_size);
@@ -66,16 +79,19 @@
]; ];
} }
function select_all(current_displays: DisplayGroup[], current_selected_display_ids: string[]) { async function toggle_all_selected_displays(current_displays: DisplayGroup[]) {
const new_value = !all_selected(current_displays, current_selected_display_ids); const new_value = !$all_groups_selected;
for (const display_group of current_displays) { for (const display_group of current_displays) {
select_all_of_group(display_group, new_value); await set_select_for_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,21 +137,21 @@
> >
<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>
<Button <Button
className="aspect-square !p-1" className="aspect-square p-1!"
bg="bg-stone-600" bg="bg-stone-600"
click_function={(e) => { click_function={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -147,7 +163,7 @@
<Button <Button
title="Bildschirm nicht mehr anpinnen" title="Bildschirm nicht mehr anpinnen"
className="aspect-square !p-1" className="aspect-square p-1!"
bg="bg-stone-600" bg="bg-stone-600"
click_function={close_pinned_display} click_function={close_pinned_display}
> >
@@ -159,9 +175,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"
/> />
@@ -187,45 +203,33 @@
</span> </span>
<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 bg-stone-600"
all_selected($displays, $selected_display_ids), onclick={async () => await toggle_all_selected_displays($display_groups)}
{
bg: true,
hover: true,
active: true,
text: true
}
)}"
onclick={() => select_all($displays, $selected_display_ids)}
>
<span
>{all_selected($displays, $selected_display_ids)
? 'Alle abwählen'
: 'Alle auswählen'}</span
> >
<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
title="Bildschirme größer darstellen" title="Bildschirme größer darstellen"
className="aspect-square !p-1 rounded-r-none" className="aspect-square p-1.5! pr-1! rounded-r-none"
bg="bg-stone-600" bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, 1))} disabled={!Boolean(next_height_step_size('display', $current_height, 1))}
click_function={() => { click_function={() => {
change_height('display', 1); change_height('display', 1);
}} }}
> >
<Plus /> <ZoomIn class="size-full" />
</Button> </Button>
<Button <Button
title="Bildschirme kleiner darstellen" title="Bildschirme kleiner darstellen"
className="aspect-square !p-1 rounded-l-none" className="aspect-square p-1.5! pl-1! rounded-l-none"
bg="bg-stone-600" bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, -1))} disabled={!Boolean(next_height_step_size('display', $current_height, -1))}
click_function={() => { click_function={() => {
change_height('display', -1); change_height('display', -1);
}} }}
> >
<Minus /> <ZoomOut class="size-full" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -234,30 +238,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 +271,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}
/> />
@@ -3,44 +3,65 @@
ClipboardPaste, ClipboardPaste,
Download, Download,
FolderPlus, FolderPlus,
Info,
Minus,
Pen, Pen,
Plus,
RefreshCcw, RefreshCcw,
Scissors, Scissors,
Trash2, Trash2,
Upload Upload,
ZoomIn,
ZoomOut
} from 'lucide-svelte'; } from 'lucide-svelte';
import { change_height, current_height, next_height_step_size } from '../ts/stores/ui_behavior'; import { change_height, current_height, next_height_step_size } from '$lib/ts/stores/ui_behavior';
import Button from './Button.svelte'; import Button from '$lib/components/Button.svelte';
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 '$lib/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,
} from '../ts/stores/files'; get_displays_where_path_exists,
create_folder_on_all_selected_displays
} from '$lib/ts/stores/files';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import FolderElementObject from './FolderElementObject.svelte'; import InodeElement from '../lib/components/InodeElement.svelte';
import PopUp from './PopUp.svelte'; import PopUp from '$lib/components/PopUp.svelte';
import type { PopupContent } from '../ts/types'; import { get_file_primary_key, type Inode, type PopupContent } from '$lib/ts/types';
import TextInput from './TextInput.svelte'; import TextInput from '$lib/components/TextInput.svelte';
import { is_valid_name } from '../ts/utils'; import {
import { displays, get_display_by_id, run_on_all_selected_displays } from '../ts/stores/displays'; first_letter_is_valid,
import { create_folders, delete_files, rename_file } from '../ts/api_handler'; get_accepted_file_type_string,
import { get } from 'svelte/store'; is_valid_name
import HighlightedText from './HighlightedText.svelte'; } from '$lib/ts/utils';
import { delete_files, rename_file } from '$lib/ts/api_handler';
import HighlightedText from '$lib/components/HighlightedText.svelte';
import { liveQuery, type Observable } from 'dexie';
import { download_file, add_upload } from '$lib/ts/file_transfer_handler';
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: Observable<Inode[]> | undefined = $state();
$effect(() => {
const s = $selected_file_ids;
selected_files = liveQuery(() => get_selected_files(s));
});
let current_folder_elements: Observable<Inode[]> | undefined = $state();
$effect(() => {
const path = $current_file_path,
display_ids = $selected_display_ids;
current_folder_elements = liveQuery(() => get_current_folder_elements(path, display_ids));
});
let one_file_selected: Observable<boolean> | undefined = $state();
$effect(() => {
const s = $selected_file_ids;
one_file_selected = liveQuery(async () => {
return s.length === 1 && (await get_file_by_id(s[0]))?.type !== 'inode/directory';
})
})
let popup_content: PopupContent = $state({ let popup_content: PopupContent = $state({
open: false, open: false,
snippet: null, snippet: null,
@@ -48,42 +69,46 @@
closable: true closable: true
}); });
let file_input: HTMLInputElement;
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) {
const display = get_display_by_id(display_id, $displays);
if (!display) continue;
const path_data = get_longest_existing_path_and_needed_parts(
$current_file_path,
display_id,
$all_files
);
await create_folders(display.ip, path_data.existing, [
...path_data.needed,
current_name.trim()
]);
}
await update_current_folder_on_selected_displays();
popup_close_function(); popup_close_function();
await create_folder_on_all_selected_displays(
current_name.trim(),
$current_file_path,
$selected_display_ids
);
await update_current_folder_on_selected_displays();
} }
async function edit_file_name(new_file_name: string) { async function edit_file_name(new_file_name: string) {
popup_close_function();
await run_for_selected_files_on_selected_displays(async (ip: string, file_names: string[]) => { await run_for_selected_files_on_selected_displays(async (ip: string, file_names: string[]) => {
if (file_names.length !== 1) { if (file_names.length !== 1) {
console.log('EEEERRRRROOOOOOR', file_names); console.error(file_names);
return; // Error return; // Error
} }
await rename_file(ip, $current_file_path, file_names[0], new_file_name); await rename_file(ip, $current_file_path, file_names[0], new_file_name);
}); });
await update_current_folder_on_selected_displays(); await update_current_folder_on_selected_displays();
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 +124,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 +151,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,17 +172,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'];
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 (!first_letter_is_valid(trimmed_input))
return [false, `Name darf nicht mit ${trimmed_input[0]} beginnen`];
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 (($current_folder_elements ?? []).some((e) => e.name === trimmed_input))
get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).some(
(e) => e.name === trimmed_input
)
)
return [false, 'Name bereits verwendet']; return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name']; return [true, 'Gültiger Name'];
}} }}
@@ -178,30 +199,29 @@
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'];
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 (!first_letter_is_valid(trimmed_input))
return [false, `Name darf nicht mit ${trimmed_input[0]} beginnen`];
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( ($current_folder_elements ?? []).some(
(e) => (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,63 +233,67 @@
>{`${$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>
<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="button space font-bold" click_function={popup_close_function}>
Abbrechen
</Button>
<Button <Button
hover_bg="bg-red-400" className="button error space"
active_bg="bg-red-500"
className="px-4 flex text-red-400 hover:text-stone-100"
click_function={async () => { click_function={async () => {
popup_close_function();
await run_for_selected_files_on_selected_displays( await run_for_selected_files_on_selected_displays(
async (ip: string, file_names: string[]) => { async (ip: string, file_names: string[]) => {
await delete_files(ip, $current_file_path, file_names); await delete_files(ip, $current_file_path, file_names);
} }
); );
await update_current_folder_on_selected_displays(); await update_current_folder_on_selected_displays();
popup_close_function();
}}>Löschen</Button }}>Löschen</Button
> >
</div> </div>
{/snippet} {/snippet}
{#snippet clipboard_hover_snippet()} <input
<div></div> class="hidden"
{/snippet} type="file"
bind:this={file_input}
multiple
accept={get_accepted_file_type_string()}
onchange={(e) =>
add_upload((e.target as HTMLInputElement).files!, $selected_display_ids, $current_file_path)}
/>
<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">
Dateien anzeigen und verwalten Dateien anzeigen und verwalten
</span> </span>
<div class="flex flex-ro"> <div class="flex flex-row">
<Button <Button
title="Dateien größer darstellen" title="Dateien größer darstellen"
className="aspect-square !p-1 rounded-r-none" className="aspect-square p-1.5! pr-1! rounded-r-none"
bg="bg-stone-600" bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('file', $current_height, 1))} disabled={!Boolean(next_height_step_size('file', $current_height, 1))}
click_function={() => { click_function={() => {
change_height('file', 1); change_height('file', 1);
}} }}
> >
<Plus /> <ZoomIn class="size-full" />
</Button> </Button>
<Button <Button
title="Dateien kleiner darstellen" title="Dateien kleiner darstellen"
className="aspect-square !p-1 rounded-l-none" className="aspect-square p-1.5! pl-1! rounded-l-none"
bg="bg-stone-600" bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('file', $current_height, -1))} disabled={!Boolean(next_height_step_size('file', $current_height, -1))}
click_function={() => { click_function={() => {
change_height('file', -1); change_height('file', -1);
}} }}
> >
<Minus /> <ZoomOut class="size-full" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -288,12 +312,16 @@
<Button <Button
title="Datei(en) hochladen" title="Datei(en) hochladen"
className="px-3 flex" className="px-3 flex"
click_function={() => {
if (file_input) file_input.click();
}}
disabled={$selected_display_ids.length === 0}><Upload /></Button disabled={$selected_display_ids.length === 0}><Upload /></Button
> >
<Button <Button
title="Ausgewählte Datei(en) herunterladen" title="Ausgewählte Datei herunterladen"
className="px-3 flex" className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Download /></Button click_function={() => (download_file($selected_file_ids[0], $selected_display_ids))}
disabled={!$one_file_selected}><Download /></Button
> >
<div class="border border-stone-700 my-1"></div> <div class="border border-stone-700 my-1"></div>
<Button <Button
@@ -312,7 +340,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,19 +370,19 @@
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'
: 'den ausgewählten Bildschirmen'} im aktuellen Ordner. Klicke auf <HighlightedText : 'den ausgewählten Bildschirmen'} im aktuellen Ordner. Klicke auf <HighlightedText
bg="bg-stone-700" bg="bg-stone-700"
fg="text-stone-400" fg="text-stone-400"
className="!p-1"><Upload class="inline pb-1" /></HighlightedText className="p-1!"><Upload class="inline pb-1" /></HighlightedText
> um Datei(en) hochzuladen. > um Datei(en) hochzuladen.
</span> </span>
{/if} {/if}
+106
View File
@@ -0,0 +1,106 @@
<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);
const current_keys: string[] = $state([]);
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(e: KeyboardEvent, key_down: boolean) {
if (!active) return;
const id = key_map[e.code];
if (!id) return;
if (key_down) {
if (current_keys.includes(e.code)) return;
current_keys.push(e.code);
} else {
const index = current_keys.indexOf(e.code);
if (index === -1) return;
current_keys.splice(index, 1);
}
if (e.repeat) return;
e.preventDefault();
e.stopPropagation();
const action: 'press' | 'release' = key_down ? 'press' : 'release';
add_to_last_keys(action.toUpperCase() + ' ' + e.code);
await run_on_all_selected_displays(
(d) => send_keyboard_input(d.ip, [{ key: id, action }]),
true
);
}
async function release_all_pressed_keys() {
const inputs: {key: string; action: 'press' | 'release' }[] = [];
for (let i = current_keys.length - 1; i >= 0; i--) {
inputs.push({key: current_keys[i], action: 'release'})
current_keys.splice(i, 1);
}
await run_on_all_selected_displays(
(d) => send_keyboard_input(d.ip, inputs),
true
);
}
</script>
<div
role="textbox"
tabindex="0"
bind:this={el}
onclick={() => {
if (active) {
el.blur();
} else {
el.focus();
active = true;
}
}}
onblur={async () => {
active = false;
await release_all_pressed_keys();
}}
onkeydown={(e) => on_key(e, true)}
onkeyup={(e) => on_key(e, false)}
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 max-h-[60vh] overflow-y-auto">{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>
@@ -1,16 +1,17 @@
<script lang="ts"> <script lang="ts">
import { ChevronRight, House } from 'lucide-svelte'; import { ChevronRight, House } from 'lucide-svelte';
import Button from './Button.svelte'; import Button from '$lib/components/Button.svelte';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import type { MenuOption } from '../ts/types'; import type { MenuOption } from '$lib/ts/types';
import { change_file_path, current_file_path } from '../ts/stores/files'; import { change_file_path, current_file_path } from '$lib/ts/stores/files';
import { fade } from 'svelte/transition';
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 +80,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 +114,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 +148,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" />
@@ -6,17 +6,16 @@
import { import {
Baseline, Baseline,
Bold, Bold,
Code,
Highlighter, Highlighter,
Italic, Italic,
PaintBucket, PaintBucket,
QrCode, QrCode,
Strikethrough Strikethrough
} from 'lucide-svelte'; } from 'lucide-svelte';
import Button from './Button.svelte'; import Button from '$lib/components/Button.svelte';
import { run_on_all_selected_displays } from '../ts/stores/displays'; import { run_on_all_selected_displays } from '$lib/ts/stores/displays';
import { show_html } from '../ts/api_handler'; import { show_html } from '$lib/ts/api_handler';
import { get_selectable_color_classes } from '../ts/stores/ui_behavior'; import { get_selectable_color_classes } from '$lib/ts/stores/ui_behavior';
import { TextStyle } from '@tiptap/extension-text-style'; import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-text-style'; import { Color } from '@tiptap/extension-text-style';
import Highlight from '@tiptap/extension-highlight'; import Highlight from '@tiptap/extension-highlight';
@@ -114,11 +113,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>`; `<style>:root {--background-color: ${color_states.bg.value} !important;}</style>`;
run_on_all_selected_displays(show_html, true, html); await run_on_all_selected_displays((d) => show_html(d.ip, html));
} }
onMount(() => { onMount(() => {
@@ -152,9 +151,9 @@
<input type="color" bind:this={color_state.el} bind:value={color_state.value} class="hidden" /> <input type="color" bind:this={color_state.el} bind:value={color_state.value} class="hidden" />
{/each} {/each}
<div class="flex flex-row gap-2 size-full"> <div data-testid="text-popup" class="flex flex-row gap-2 size-full">
<div <div
class="rounded-xl size-full flex-shrink min-w-0 flex" class="rounded-xl size-full shrink min-w-0 flex"
style="background-color: {color_states.bg.value};" style="background-color: {color_states.bg.value};"
> >
<div bind:this={element} class="size-full overflow-auto px-3 py-2"></div> <div bind:this={element} class="size-full overflow-auto px-3 py-2"></div>
@@ -1,47 +0,0 @@
<script>
import { Minus, Settings, TvMinimalPlay } from 'lucide-svelte';
import Button from '../../components/Button.svelte';
import { pinned_display_id } from '../../ts/stores/ui_behavior';
import { fade, slide } from 'svelte/transition';
</script>
<main class="bg-stone-900 h-dvh w-dvw text-stone-200 p-4 gap-4 grid grid-rows-[3rem_auto]">
<div class="w-[calc(100dvw-(8*var(--spacing)))] flex justify-between">
<span class="text-4xl font-bold content-center h-full"> PLG MuDiCS </span>
<Button className="aspect-square" bg="bg-stone-800">
<Settings></Settings>
</Button>
</div>
<div class="w-[calc(100dvw-(8*var(--spacing)))] grid grid-cols-2 gap-2">
<div class="h-[calc(100dvh-3rem-(12*var(--spacing)))] flex flex-col gap-2"></div>
<div
class="col-start-2 h-[calc(100dvh-3rem-(12*var(--spacing)))] rounded-2xl flex flex-col gap-2"
>
<div class="bg-stone-800 h-full rounded-2xl grid grid-rows-[2.5rem_auto]">
<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"></div>
</div>
<div class="flex flex-col gap-2 p-2 max-w-full overflow-hidden min-w-0">
<div class="flex flex-row justify-between gap-4 min-w-0">
<div class="flex flex-row gap-2 min-w-0 flex-1">
<Button className="px-3 flex items-center gap-3 min-w-0 flex-1 justify-start">
<TvMinimalPlay />
<!-- Text: nimmt Restbreite, darf schrumpfen & ellipsieren -->
<span class="truncate min-w-0 flex-1">Sehr lange Beschriftung für den Button</span>
</Button>
<div class="relative min-w-0 flex">
<button class="text-xl font-bold pl-2 content-center truncate min-w-0">
Dateien anzeigen und verwalten
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
-245
View File
@@ -1,245 +0,0 @@
import { notifications } from "./stores/notification";
import { to_display_status, type DisplayStatus, type FolderElement, type RequestResponse, type ShellCommandResponse, type TreeElement } from "./types";
import { get_uuid } from "./utils";
export async function get_screenshot(ip: string): Promise<Blob | null> {
const options = { method: 'PATCH' };
const response = await request_display(ip, '/takeScreenshot', options);
if (!response.ok || !response.blob) return null;
return response.blob;
}
export async function open_file(ip: string, path_to_file: string): Promise<void> {
const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } };
await request_display(ip, `/file${path_to_file}`, options);
}
export async function send_keyboard_input(ip: string, key: string): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
key: key,
}),
};
await request_display(ip, '/keyboardInput', options);
}
export async function show_html(ip: string, html: string) {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: html
})
};
await request_display(ip, '/showHTML', options);
}
export async function get_file_data(ip: string, path: string): Promise<FolderElement[] | null> {
interface FileInfo {
name: string;
type: string;
size: string;
created: string;
}
const command = `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do
typ=$(file -b --mime-type -- "$f")
size=$(stat -c '%s' -- "$f")
created=$(stat -c '%w' -- "$f")
[ "$created" = "-" ] && created=$(stat -c '%y' -- "$f")
jq -n --arg name "$f" --arg type "$typ" --arg size "$size" --arg created "$created" \
'{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n'
echo
done
`
const raw_response = await run_shell_command(ip, command, true);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (json_response.exitCode === 0 && json_response.stdout.trim() === '') return [];
if (handle_shell_error(ip, json_response, command, true)) return null;
const response: FileInfo[] = json_response.stdout.trim()
.split("\n")
.filter(Boolean)
.map((line: string) => JSON.parse(line) as FileInfo);
const folder_element_list: FolderElement[] = [];
for (const response_element of response) {
// filter hidden files (start with '.' -> './.config')
if (response_element.name.charAt(2) !== '.') {
const truncated = {
...response_element,
created: response_element.created.slice(0, 16) // truncated to YYYY-MM-DD hh-mm -> no (milli)seconds
};
const folder_element: FolderElement = {
id: get_uuid(),
hash: JSON.stringify(truncated),
name: response_element.name.slice(2), // remove "./"
type: response_element.type,
date_created: new Date(response_element.created),
size: Number(response_element.size),
};
folder_element_list.push(folder_element);
}
}
return folder_element_list;
}
export async function get_file_tree_data(ip: string, path: string): Promise<TreeElement[] | null> {
const command = `cd ".${path}" && tree -Js`
const raw_response = await run_shell_command(ip, command, true);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (handle_shell_error(ip, json_response, command, true)) return null;
return (JSON.parse(json_response.stdout.trim()) as [TreeElement, any])[0].contents || null;
}
export async function create_folders(ip: string, path: string, folder_names: string[]): Promise<void> {
let command = `cd ".${path}"`;
for (const part of folder_names) {
command += ` && mkdir "${part}" && cd "${part}/"`;
}
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function rename_file(ip: string, path: string, old_file_name: string, new_file_name: string): Promise<void> {
const command: string = `cd ".${path}" && mv "${old_file_name}" "${new_file_name}"`;
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function delete_files(ip: string, current_path: string, file_names: string[]): Promise<void> {
let command: string = `cd ".${current_path}"`;
for (const file_name of file_names) {
command += ` && rm -r "${file_name}"`;
}
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function show_blackscreen(ip: string): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: ``
})
};
await request_display(ip, '/showHTML', options);
}
export async function get_thumbnail_blob(ip: string, path_to_file: string): Promise<Blob | null> {
const raw_response = await request_display(ip, `/file/preview${path_to_file}`, { method: 'GET' }, true, [415]);
if (!raw_response.ok || !raw_response.blob) return null
return raw_response.blob;
}
export async function ping_ip(ip: string): Promise<DisplayStatus> {
const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' });
if (!raw_response.ok || !raw_response.json) return null;
return raw_response.json.status ? to_display_status(raw_response.json.status) : null;
}
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> {
const url = `http://${ip}:1323/api${api_route}`;
return await request(url, options, log_in_debug, supress_error_handling_http_codes);
}
async function request_control(api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<RequestResponse> {
const url = `${window.location.origin}/api${api_route}`;
return await request(url, options);
}
async function request(url: string, options: { method: string, headers?: Record<string, string>, body?: any }, log_in_debug: boolean = false, supress_error_handling_http_codes: number[] = []): Promise<RequestResponse> {
try {
const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`;
if (log_in_debug) {
console.debug(url + cache_buster, options.body);
} else {
console.log(url + cache_buster, options.body);
}
const response = await fetch(url + cache_buster, options);
if (response.ok || supress_error_handling_http_codes.includes(response.status)) {
const contentType = response.headers.get("content-type") || "";
let request_response: RequestResponse;
if (!contentType.includes("application/json")) {
const blob: Blob = await response.blob();
request_response = { ok: response.ok, http_code: response.status, blob: blob };
} else {
const json: Object = await response.json();
request_response = { ok: response.ok, http_code: response.status, json: json };
}
if (log_in_debug) {
console.debug(request_response);
} else {
console.log(request_response);
}
return request_response;
}
let error_description: string;
try {
const json: { description: string } = await response.json();
error_description = json.description;
} catch (error_on_json_parsing: any) {
error_description = `unknown error: ${error_on_json_parsing}`;
}
console.error(url, error_description);
notifications.push("error", `Fehler ${response.status} bei API-Anfrage`, `${url}\nFehler: ${error_description}`);
} catch (error: any) {
if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) {
console.log("Request failed - Is the targeted device online?")
} else {
console.error(url, error);
notifications.push("error", `Fataler Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
}
}
return { ok: false };
}
function handle_shell_error(ip: string, shell_response: ShellCommandResponse, shell_command: string, command_includs_cd: boolean): boolean {
if (shell_response.exitCode !== 0) {
if (command_includs_cd && shell_response.stderr && /bash: line \d+: cd: .+: No such file or directory/.test(shell_response.stderr)) {
console.log("current file_path does not exist on display:", ip);
return true;
}
console.error(shell_response);
notifications.push("error", `Fehler ${shell_response.exitCode} in API-Shell`, `${ip}\n${shell_command}\nFehler: ${shell_response.stderr}`);
return true;
}
if (shell_response.stdout.trim() === '') return true;
return false;
}
async function run_shell_command(ip: string, command: string, log_in_debug: boolean = false): Promise<RequestResponse> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: command
})
};
return await request_display(ip, '/shellCommand', options, log_in_debug);
}
@@ -1,20 +0,0 @@
import Dexie, { type EntityTable } from "dexie";
export interface ThumbnailBlobDBEntry {
hash: string;
blob: Blob;
}
export class FileDatabase extends Dexie {
thumbnail_blobs!: EntityTable<ThumbnailBlobDBEntry, "hash">; // (Type, PrimaryKey)
constructor() {
super("FileDatabase");
this.version(1).stores({
thumbnail_blobs: "++hash, blob" // primary key
});
}
}
export const db = new FileDatabase();
-34
View File
@@ -1,34 +0,0 @@
import { get } from "svelte/store";
import { displays, run_on_all_selected_displays, update_displays_with_map, update_screenshot } from "./stores/displays"
import { ping_ip } from "./api_handler";
import type { Display } from "./types";
import { change_file_path, update_folder_elements_recursively } from "./stores/files";
import { db } from "./indexdb/file_thumbnails.db";
const update_display_status_interval_seconds = 20;
export async function on_start() {
await db.thumbnail_blobs.clear();
await update_all_display_status();
await setInterval(update_all_display_status, update_display_status_interval_seconds * 1000);
}
async function update_all_display_status() {
await update_displays_with_map(async (display: Display) => {
const new_status = await ping_ip(display.ip);
if (new_status === null && display.status !== null) return display;
if (new_status === "app_online" && display.status !== "app_online") {
await on_display_start(display);
}
return { ...display, status: new_status, };
});
console.log("Display Status updated")
}
async function on_display_start(display: Display) {
await update_folder_elements_recursively(display, '/');
await update_screenshot(display.id);
}
-191
View File
@@ -1,191 +0,0 @@
import { get, writable, type Writable } from "svelte/store";
import type { Display, DisplayGroup, DisplayStatus } from "../types";
import { is_selected, select, selected_display_ids } from "./select";
import { get_uuid, image_content_hash } from "../utils";
import { get_screenshot } from "../api_handler";
import { filter_file_selection_for_current_selected_displays } from "./files";
export const displays: Writable<DisplayGroup[]> = writable<DisplayGroup[]>([{
id: get_uuid(),
data: []
}]);
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) {
displays.update((displays: DisplayGroup[]) => {
displays[0].data.push({ id: get_uuid(), ip, preview_url: null, preview_timeout_id: null, mac, name, status });
return displays;
});
}
export async function edit_display_data(display_id: string, ip: string, mac: string | null, name: string) {
await update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display;
return { ...display, ip: ip, mac: mac, name: name };
})
}
export function remove_display(display_id: string) {
select(selected_display_ids, display_id, false);
filter_file_selection_for_current_selected_displays();
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.
}
export function all_displays_of_group_selected(display_group: DisplayGroup, current_selected_displays: string[]) {
if (display_group.data.length === 0) return false;
for (const display of display_group.data) {
if (!is_selected(display.id, current_selected_displays)) {
return false;
}
}
return true;
}
export function select_all_of_group(display_group: DisplayGroup, new_value: boolean | null = null) {
for (const display of display_group.data) {
select(selected_display_ids, display.id, new_value);
}
filter_file_selection_for_current_selected_displays();
}
export function set_new_display_group_data(display_group_id: string, new_data: Display[]) {
displays.update((displays: DisplayGroup[]) => {
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[]) {
const displays_array = display_group_array;
for (const display_group of displays_array) {
for (const display of display_group.data) {
if (display.id === display_id) {
return display;
}
}
}
return null;
}
export function add_empty_display_group() {
displays.update((displays: DisplayGroup[]) => {
displays.push({
id: get_uuid(),
data: [],
});
return displays;
});
}
export function remove_empty_display_groups() {
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) {
update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display;
return { ...display, preview_url: null, preview_timeout_id: null };
})
return;
}
const display = get_display_by_id(display_id, get(displays));
let update_needed = check_type === "first_check";
if (check_type !== "first_check") {
if (display && display.preview_url) {
const old_blob = await fetch(display.preview_url).then(r => r.blob());
const new_hash = await image_content_hash(new_blob);
const old_hash = await image_content_hash(old_blob);
update_needed = old_hash !== new_hash; // if different -> update
}
}
let new_preview_timeout_id: number | null = null;
if (update_needed || check_type === "last_check_different") {
new_preview_timeout_id = setTimeout(async () => { await update_screenshot(display_id, update_needed ? "last_check_different" : "last_check_same") }, 2 * 1000);
}
if (display?.preview_timeout_id) {
clearInterval(display.preview_timeout_id);
}
if (update_needed) {
update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display;
if (display.preview_url) {
URL.revokeObjectURL(display.preview_url);
}
const new_url = URL.createObjectURL(new_blob);
return { ...display, preview_url: new_url, preview_timeout_id: new_preview_timeout_id };
})
}
}
export async function update_displays_with_map(update_function: (display: Display) => Display | Promise<Display>) {
const display_groups = get(displays);
const updated_groups = await Promise.all(
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)) {
const display_ip = get_display_by_id(display_id, get(displays))?.ip;
if (display_ip) {
await run_function(display_ip, ...args)
if (update_screenshot_afterwards) {
await update_screenshot(display_id);
}
}
}
}
add_testing_displays();
function add_testing_displays() {
// const names = ["Vorne Rechts", "Vorne Links", "Vorne Mitte", "Fernseher Rechts", "Fernseher Bühne", "UIUIUIUIUIUIUISEHRLANGERTEXT DER IST WIRKLICH LANG, DER TEXT, so lang, dass er wirklich nirgendswo hinpasst, nichtmal da oben /\\"];
// for (const name of names) {
// add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", name, "Offline");
// }
add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", "PC", "host_offline");
// add_display("192.168.178.111", "D4:81:D7:C0:DF:3C", "Laptop", "host_offline");
}
-317
View File
@@ -1,317 +0,0 @@
import { get, writable, type Writable } from "svelte/store";
import type { Display, FolderElement, TreeElement } from "../types";
import { displays, get_display_by_id } from "./displays";
import { select, selected_display_ids, selected_file_ids } from "./select";
import { get_file_data, get_file_tree_data } from "../api_handler";
import { notifications } from "./notification";
import { CirclePoundSterling } from "lucide-svelte";
import { deactivate_old_thumbnail_urls, generate_thumbnail } from "./thumbnails";
export const all_files: Writable<Record<string, Record<string, FolderElement[]>>> = writable<Record<string, Record<string, FolderElement[]>>>({});
// {
// path: {
// display_id: FolderElement[]
// ...
// },
// path2: {
// display_id: FolderElement[]
// ...
// },
// ...
// }
export const current_file_path: Writable<string> = writable<string>('/');
export async function change_file_path(new_path: string) {
current_file_path.update(() => {
return new_path;
});
selected_file_ids.update(() => {
return [];
})
deactivate_old_thumbnail_urls();
for (const display_group of get(displays)) {
for (const display of display_group.data) {
const changed_paths = await get_changed_directory_paths(display, new_path);
if (!changed_paths) continue;
console.log("Update file system from", display.name, ":", changed_paths);
for (const path of changed_paths) {
update_folder_elements_recursively(display, path);
}
}
}
}
export async function filter_file_selection_for_current_selected_displays() {
for (const selected_file_id of get(selected_file_ids)) {
if (!get_file_by_id(selected_file_id, get(all_files), get(current_file_path), true)) {
// file not found in selected displays
select(selected_file_ids, selected_file_id, false);
}
}
}
export async function update_current_folder_on_selected_displays() {
selected_file_ids.update(() => {
return [];
});
const current_path = get(current_file_path);
for (const display_id of get(selected_display_ids)) {
const display = get_display_by_id(display_id, get(displays));
if (!display) continue;
update_folder_elements_recursively(display, current_path);
}
}
export function get_display_ids_where_file_is_missing(path: string, file: FolderElement, selected_display_ids: string[], all_files: Record<string, Record<string, FolderElement[]>>): string[][] {
if (!all_files.hasOwnProperty(path)) return [selected_display_ids, []];
const missing: string[] = [];
const colliding: string[] = [];
Display:
for (const selected_display_id of selected_display_ids) {
if (!all_files[path].hasOwnProperty(selected_display_id)) {
missing.push(selected_display_id);
continue;
}
for (const folder_element of all_files[path][selected_display_id]) {
if (folder_element.name === file.name) {
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[] {
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);
const directory_strings = get_recursive_changed_directory_paths(display, file_path, current_folder, get(all_files));
if (directory_strings.size === 0) return null;
const directory_strings_array = [...directory_strings];
return directory_strings_array.filter((e) => (!directory_strings_array.some((f) => (f !== e && f.startsWith(e)))));
}
function get_recursive_changed_directory_paths(display: Display, current_file_path: string, current_folder_elements: TreeElement[] | null, files: Record<string, Record<string, FolderElement[]>>): Set<string> {
const files_folder: FolderElement[] = files[current_file_path][display.id];
if ((!files_folder || files_folder.length === 0) && (!current_folder_elements || current_folder_elements.length === 0)) {
return new Set([]); // no data -> no update needed
} else if (!files_folder || !current_folder_elements || current_folder_elements.length !== files_folder.length) {
return new Set([current_file_path]); // existing data does not match new data -> update
}
let has_changed: Set<string> = new Set();
for (const tree_folder_element of current_folder_elements) {
const folder_element = files_folder.find(e => e.name === tree_folder_element.name);
if (!folder_element || (tree_folder_element.type !== "directory" && folder_element.size !== tree_folder_element.size)) {
return new Set([current_file_path]);
}
if (tree_folder_element.type === "directory" && tree_folder_element.contents) {
const new_file_path = current_file_path + tree_folder_element.name + '/';
for (const string of get_recursive_changed_directory_paths(display, new_file_path, tree_folder_element.contents, files)) {
has_changed.add(string);
}
}
}
return has_changed;
}
export async function update_folder_elements_recursively(display: Display, file_path: string = '/'): Promise<number> {
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] = {};
}
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:
setTimeout(async () => {
for (const folder_element of diff.new) {
await generate_thumbnail(display.ip, file_path, folder_element);
}
}, 0)
files[file_path][display.id].push(...diff.new);
return remove_folder_elements_recursively(files, display, diff.deleted, file_path);
})
let folder_size = 0;
for (const element of new_folder_elements) {
if (element.type === 'inode/directory') {
const folder_content_size = await update_folder_elements_recursively(display, file_path + element.name + '/');
folder_size += folder_content_size;
// Update foldersize
all_files.update((files: Record<string, Record<string, FolderElement[]>>) => {
for (const current_folder_element of files[file_path][display.id]) {
if (current_folder_element.id === element.id) {
current_folder_element.size = folder_content_size;
}
}
return files;
})
} else {
folder_size += element.size;
}
}
return folder_size;
}
function remove_folder_elements_recursively(files: Record<string, Record<string, FolderElement[]>>, display: Display, folder_elements: FolderElement[], file_path: string): Record<string, Record<string, FolderElement[]>> {
if (!files.hasOwnProperty(file_path) || !files[file_path].hasOwnProperty(display.id)) {
console.error("File remove path does not exist:", files, display, folder_elements, file_path);
notifications.push("error", "Fehler beim Aktualisieren der Dateien", `File remove path does not exist: ${file_path} display_ip: ${display.ip}`);
return {};
}
for (const folder_element of folder_elements) {
files[file_path][display.id] = files[file_path][display.id].filter((f) => f.id !== folder_element.id);
if (folder_element.type === 'inode/directory') {
const new_file_path = file_path + folder_element.name + '/';
if (!files.hasOwnProperty(new_file_path) || !files[new_file_path].hasOwnProperty(display.id)) {
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 };
}
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[]) {
files.sort((a, b) => {
const isDirA = a.type === 'inode/directory';
const isDirB = b.type === 'inode/directory';
// Ordner zuerst
if (isDirA && !isDirB) return -1;
if (!isDirA && isDirB) return 1;
// Danach alphabetisch nach name (case-insensitive)
const nameCompare = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
if (nameCompare !== 0) return nameCompare;
// Wenn name gleich, absteigend nach date_created
if (!b.date_created || !a.date_created) return -1;
return b.date_created.getTime() - a.date_created.getTime();
});
return files;
}
export function get_file_by_id(file_id: string, all_files: Record<string, Record<string, FolderElement[]>>, current_file_path: string, only_from_selected_displays: boolean = false): FolderElement | null {
let current_path_elements: Record<string, FolderElement[]> | undefined = all_files[current_file_path];
if (!current_path_elements) return null;
if (only_from_selected_displays) {
current_path_elements = Object.fromEntries(
Object.entries(current_path_elements).filter(([key]) => get(selected_file_ids).includes(key))
);
}
const all_folder_elements = Object.values(current_path_elements).flat();
const found = all_folder_elements.find(el => el.id === file_id);
if (!found) return null;
return found
}
export async function run_for_selected_files_on_selected_displays(action: (ip: string, file_names: string[]) => Promise<void>): Promise<void> {
const files = get(all_files);
const file_path = get(current_file_path);
const folder_elements: FolderElement[] = get(selected_file_ids)
.map((file_id) => get_file_by_id(file_id, files, file_path))
.filter((element) => element !== null);
const folder_element_hashs = folder_elements
.map((folder_element) => folder_element.hash)
.filter((hash) => hash !== null);
for (const display_id of get(selected_display_ids)) {
if (!files[file_path].hasOwnProperty(display_id)) continue;
const files_on_display = files[file_path][display_id];
const selected_file_names_on_display: string[] = files_on_display
.filter((e) => (e.hash && folder_element_hashs.includes(e.hash)) || (e.type === 'inode/directory' && folder_elements.some(e2 => e2.name === e.name && e2.type === 'inode/directory')))
.map((folder_element) => folder_element.name);
if (selected_file_names_on_display.length === 0) continue;
const display = get_display_by_id(display_id, get(displays));
if (!display) continue;
await action(display.ip, selected_file_names_on_display);
}
}
export function get_longest_existing_path_and_needed_parts(path: string, display_id: string, all_files: Record<string, Record<string, FolderElement[]>>): { existing: string; needed: string[] } {
const path_parts = path.slice(1, path.length - 1).split('/').filter(e => e.length !== 0); // remove front and back / and split after that, then remove empty strings
for (let i = path_parts.length; i > 0; i--) {
const current_path = '/' + [...path_parts].splice(0, i).join('/') + '/';;
if (all_files.hasOwnProperty(current_path)) {
if (all_files[current_path].hasOwnProperty(display_id)) {
return { existing: current_path, needed: [...path_parts].splice(i) };
}
}
}
return { existing: '/', needed: path_parts };
}
-23
View File
@@ -1,23 +0,0 @@
import { writable, type Writable } from "svelte/store";
export const selected_file_ids: Writable<string[]> = writable<string[]>([]);
export const selected_display_ids: Writable<string[]> = writable<string[]>([]);
export function select(selected_ids: Writable<string[]>, id: string, new_value: boolean | null = null) {
selected_ids.update((all_ids: string[]) => {
if (all_ids.includes(id)) {
const index = all_ids.indexOf(id);
if (index > -1 && new_value !== true) {
all_ids.splice(index, 1);
}
} else if (new_value !== false) {
all_ids.push(id);
}
return all_ids;
});
}
export function is_selected(id: string, selected_ids: string[]): boolean {
return selected_ids.includes(id);
}
@@ -1,38 +0,0 @@
import { get, writable, type Writable } from "svelte/store";
import { get_thumbnail_blob } from "../api_handler";
import { type FolderElement } from "../types";
import { db, type ThumbnailBlobDBEntry } from "../indexdb/file_thumbnails.db";
import { get_file_type } from "../utils";
export const active_thumbnail_urls: Writable<string[]> = writable<string[]>([]);
export async function generate_thumbnail(display_ip: string, path: string, folder_element: FolderElement): Promise<void> {
const supported_file_type = get_file_type(folder_element);
if (!supported_file_type || !folder_element.hash) return;
const hash: string = folder_element.hash;
if (await db.thumbnail_blobs.get(hash)) return;
const thumbnail_blob = await get_thumbnail_blob(display_ip, path + folder_element.name);
if (!thumbnail_blob) return;
await db.thumbnail_blobs.add({ hash: hash, blob: thumbnail_blob });
}
export async function get_thumbnail_url(hash: string | null): Promise<string | null> {
if (hash === null) return null;
const thumbnail_blob = await db.thumbnail_blobs.get(hash) as ThumbnailBlobDBEntry;
if (!thumbnail_blob) return null;
const new_url = URL.createObjectURL(thumbnail_blob.blob);
active_thumbnail_urls.update((current: string[]) => {
current.push(new_url);
return current;
})
return new_url;
}
export function deactivate_old_thumbnail_urls() {
const current_urls = get(active_thumbnail_urls);
for (const url of current_urls) {
URL.revokeObjectURL(url);
}
active_thumbnail_urls.update(() => { return []; })
}
-89
View File
@@ -1,89 +0,0 @@
import { FileBox, FileImage, FileText, FileVideoCamera, ImagePlay, type X } from "lucide-svelte";
import type { Snippet } from "svelte";
export type RequestResponse = {
ok: boolean,
http_code?: number
blob?: Blob,
json?: any,
}
export type ShellCommandResponse = {
stdout: string,
stderr: string,
exitCode: number,
}
export type SupportedFileType = {
display_name: string;
mime_type: string;
};
export const supported_file_type_icon: Record<string, typeof X> = {
'MP4': FileVideoCamera,
'JPG': FileImage,
'PNG': FileImage,
'GIF': ImagePlay,
'PPTX': FileBox,
'ODP': FileBox,
'PDF': FileText
}
export type FolderElement = {
id: string;
hash: string;
name: string;
type: string;
date_created: Date | null;
size: number;
}
export type TreeElement = {
contents?: TreeElement[];
type: "file" | "directory";
name: string;
size: number;
}
export type Display = {
id: string;
ip: string;
preview_url: string | null;
preview_timeout_id: number | null;
mac: string | null;
name: string;
status: DisplayStatus;
}
export type DisplayGroup = {
id: string;
data: Display[];
};
export type MenuOption = {
icon?: typeof X;
name: string;
class?: string;
on_select?: () => void;
disabled?: boolean;
}
export type PopupContent = {
open: boolean;
snippet: Snippet<[string]> | null;
snippet_arg?: string;
title?: string;
title_class?: string;
title_icon?: typeof X | null;
window_class?: string;
closable?: boolean;
}
export type DisplayStatus = "host_offline" | "app_offline" | "app_online" | null;
export function to_display_status(value: string): DisplayStatus {
return ["host_offline", "app_offline", "app_online"].includes(value)
? (value as DisplayStatus)
: null;
}
-82
View File
@@ -1,82 +0,0 @@
import type { DisplayStatus, FolderElement, SupportedFileType } from "./types";
import supported_file_types_json from './../../../../shared/supported_file_types.json';
const supported_file_types: Record<string, SupportedFileType> = supported_file_types_json as Record<string, SupportedFileType>;
export function get_file_type(file: FolderElement): SupportedFileType | null {
for (const key of Object.keys(supported_file_types)) {
if (file.type === supported_file_types[key].mime_type) {
return supported_file_types[key];
}
}
// Fallback:
const extension = file.name.split('.').pop();
if (extension) {
if (Object.keys(supported_file_types).includes('.' + extension)) {
return supported_file_types['.' + extension];
}
}
return null;
}
export function get_uuid(): string {
return crypto.randomUUID();
}
export function get_file_size_display_string(size: number, toFixed: number | null = null): string {
if (size < 0) return toFixed === null ? "versch." : "Verschiedene Größen auf verschiedenen Bildschirmen";
if (size === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(size) / Math.log(k));
const value = size / Math.pow(k, i);
const size_string = `${value.toFixed(toFixed !== null ? toFixed : Math.max(0, 2 - Math.floor(Math.log10(value))))} ${sizes[i]}`;
return size_string.replace('.', ',');
}
export async function image_content_hash(blob: Blob, size = 32): Promise<number> {
// Blob → ImageBitmap (GPU-dekodiert, superschnell)
const bitmap = await createImageBitmap(blob);
// OffscreenCanvas ist sehr schnell (kein Layout nötig)
const canvas = new OffscreenCanvas(size, size);
const ctx = canvas.getContext("2d")!;
ctx.drawImage(bitmap, 0, 0, size, size);
// Pixel-Daten holen
const { data } = ctx.getImageData(0, 0, size, size);
// Einfacher, schneller Integer-Hash (FNV-1a)
let hash = 2166136261;
for (let i = 0; i < data.length; i++) {
hash ^= data[i];
hash = Math.imul(hash, 16777619);
}
bitmap.close(); // GPU-Ressourcen freigeben
return hash >>> 0; // unsigned int
}
export function is_valid_name(input: string): boolean {
return /^[\p{L}\p{N}\p{M}\-_.+,()[\]{}@!§$%&=~^ ]+$/u.test(input);
}
export function display_status_to_info(status: DisplayStatus): string {
switch (status) {
case 'app_online':
return 'Online';
case 'app_offline':
return 'Lädt';
case 'host_offline':
return 'Offline';
case null:
return '???';
}
}
+22
View File
@@ -0,0 +1,22 @@
meta {
name: wakeOnLan
type: http
seq: 4
}
post {
url: http://localhost:8080/api/wakeOnLan
body: json
auth: inherit
}
body:json {
{
"mac_address": "aa:aa:aa:aa:aa:aa"
}
}
settings {
encodeUrl: true
timeout: 0
}
+3 -2
View File
@@ -44,8 +44,9 @@ Even when the command itself fails.
### Request Body ### Request Body
- `key`: string for key - `inputs`: list
- `action`: "press" or "release" - `key`: string for key
- `action`: "press" or "release"
## PATCH `/showHTML` ## PATCH `/showHTML`
+13 -3
View File
@@ -54,16 +54,23 @@ const (
KeyRelease KeyRelease
) )
func KeyboardInput(key int, action KeyAction) error { type Input struct {
Key int
Action KeyAction
}
func KeyboardInput(inputs []Input) error {
var err error var err error
kb, err := keybd_event.NewKeyBonding() kb, err := keybd_event.NewKeyBonding()
if err != nil { if err != nil {
return fmt.Errorf("failed to create key bonding: %w", err) return fmt.Errorf("failed to create key bonding: %w", err)
} }
kb.SetKeys(key)
switch action { for _, input := range inputs {
kb.SetKeys(input.Key)
switch input.Action {
case KeyPress: case KeyPress:
err = kb.Press() err = kb.Press()
case KeyRelease: case KeyRelease:
@@ -74,6 +81,9 @@ func KeyboardInput(key int, action KeyAction) error {
return fmt.Errorf("failed to run key event: %w", err) return fmt.Errorf("failed to run key event: %w", err)
} }
time.Sleep(time.Microsecond * 100)
}
return nil return nil
} }
+7 -1
View File
@@ -12,10 +12,16 @@ patch {
body:json { body:json {
{ {
"key": "VK_T" "inputs": [
{
"key": "VK_T",
"action": "press"
}
]
} }
} }
settings { settings {
encodeUrl: true encodeUrl: true
timeout: 0
} }
+35 -13
View File
@@ -10,6 +10,7 @@ import (
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -146,12 +147,20 @@ func qrRoute(c echo.Context) error {
func extractFilePathMiddleware(next echo.HandlerFunc) echo.HandlerFunc { func extractFilePathMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error { return func(ctx echo.Context) error {
pathParam := ctx.Param("path") raw := ctx.Param("path")
fullPath, exists, err := pkg.ResolveStorageFilePath(pathParam)
decoded, err := url.PathUnescape(raw)
if err != nil { if err != nil {
slog.Warn("Failed to validate file path", "path", pathParam, "error", err) slog.Warn("Invalid path encoding", "path", raw, "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid file path"}) return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid file path"})
} }
fullPath, exists, err := pkg.ResolveStorageFilePath(decoded)
if err != nil {
slog.Warn("Failed to validate file path", "path", decoded, "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid file path"})
}
ctx.Set("fullPath", fullPath) ctx.Set("fullPath", fullPath)
ctx.Set("fileExists", exists) ctx.Set("fileExists", exists)
return next(ctx) return next(ctx)
@@ -186,38 +195,51 @@ func shellCommandRoute(ctx echo.Context) error {
func keyboardInputRoute(ctx echo.Context) error { func keyboardInputRoute(ctx echo.Context) error {
var request struct { var request struct {
Inputs []struct {
Key string `json:"key"` Key string `json:"key"`
Action string `json:"action"` Action string `json:"action"`
} `json:"inputs"`
} }
if err := ctx.Bind(&request); err != nil { if err := ctx.Bind(&request); err != nil {
slog.Error("Failed to parse keyboard input", "error", err) slog.Error("Failed to parse keyboard input", "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription}) return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
} }
if request.Action != "press" && request.Action != "release" {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription}) var inputs []pkg.Input
for _, input := range request.Inputs {
if input.Action != "press" && input.Action != "release" {
slog.Error("Invalid keyboard action", "action", input.Action)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: fmt.Sprintf("Invalid action: %s", input.Action)})
} }
code, ok := pkg.KeyboardEvents[request.Key] code, ok := pkg.KeyboardEvents[input.Key]
if !ok { if !ok {
slog.Error("Unsupported key", "key", request.Key) slog.Error("Unsupported key", "key", input.Key)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: fmt.Sprintf("Unsupported key: %s", request.Key)}) return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: fmt.Sprintf("Unsupported key: %s", input.Key)})
} }
var action pkg.KeyAction var action pkg.KeyAction
if request.Action == "press" { if input.Action == "press" {
action = pkg.KeyPress action = pkg.KeyPress
} }
if request.Action == "release" { if input.Action == "release" {
action = pkg.KeyRelease action = pkg.KeyRelease
} }
err := pkg.KeyboardInput(code, action) inputs = append(inputs, pkg.Input{
Key: code,
Action: action,
})
}
err := pkg.KeyboardInput(inputs)
if err != nil { if err != nil {
slog.Error("Failed to send keyboard input", "key", request.Key, "error", err) slog.Error("Failed to send keyboard input", "inputs", inputs, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to send keyboard input"}) return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to send keyboard input"})
} }
slog.Info("Keyboard input sent", "key", request.Key) slog.Info("Keyboard input sent")
return ctx.JSON(http.StatusOK, struct{}{}) return ctx.JSON(http.StatusOK, struct{}{})
} }
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1766997096,
"narHash": "sha256-uOUlyNs3URxA+r8U7jeMe+W2X9kRriP2DqvaeeGzkTQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "088e9a071796304ed9ac3d666164e1ccb9b53f66",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-25.11-small",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+30
View File
@@ -0,0 +1,30 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11-small";
};
outputs = {
self,
nixpkgs,
}: let
pkgs = nixpkgs.legacyPackages."x86_64-linux";
in {
devShells."x86_64-linux".default = pkgs.mkShell {
packages = with pkgs; [
libreoffice
ungoogled-chromium
xreader
imagemagick
ffmpeg
ghostscript
gnome-screenshot
playwright-driver.browsers
];
PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.browsers;
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "true";
PW_DISABLE_TS_ESM = "true";
};
};
}
+11
View File
@@ -1,9 +1,11 @@
{ {
inputs,
config, config,
pkgs, pkgs,
... ...
}: { }: {
imports = [ imports = [
inputs.home-manager.nixosModules.default
./hardware-configuration.nix ./hardware-configuration.nix
]; ];
@@ -99,6 +101,15 @@
ghostscript ghostscript
]; ];
home-manager.users.mudics = {
xfconf.settings = {
xfce4-power-manager."xfce4-power-manager/dpms-enabled" = false;
xfce4-screensaver."saver/enabled" = false;
};
home.stateVersion = "25.05";
};
systemd.services.update-mudics = { systemd.services.update-mudics = {
wantedBy = ["multi-user.target"]; wantedBy = ["multi-user.target"];
after = ["network-online.target"]; after = ["network-online.target"];
+22
View File
@@ -1,5 +1,26 @@
{ {
"nodes": { "nodes": {
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1763992789,
"narHash": "sha256-WHkdBlw6oyxXIra/vQPYLtqY+3G8dUVZM8bEXk0t8x4=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "44831a7eaba4360fb81f2acc5ea6de5fde90aaa3",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-25.05",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1761999846, "lastModified": 1761999846,
@@ -18,6 +39,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
} }
+6
View File
@@ -1,6 +1,11 @@
{ {
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
home-manager = {
url = "github:nix-community/home-manager/release-25.05";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = { outputs = {
@@ -15,6 +20,7 @@
}; };
modules = [ modules = [
./configuration.nix ./configuration.nix
inputs.home-manager.nixosModules.default
]; ];
}; };
}; };