Compare commits

..

1 Commits

Author SHA1 Message Date
2mal3 cd6ec88d23 chore(openBrowser): more detailed error logs 2025-11-07 12:42:19 +01:00
95 changed files with 2554 additions and 8791 deletions
-1
View File
@@ -5,7 +5,6 @@ on:
paths-ignore:
- "**/*.md"
- "**/*.yml"
- "**/*.sh"
workflow_dispatch:
jobs:
-4
View File
@@ -17,15 +17,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Zip NixOS Config
run: zip -rj nixos.zip nixos/
- name: Release
uses: softprops/action-gh-release@v2
with:
name: PLG-MuDiCS ${{ github.ref_name }}
body_path: CHANGELOG.md
files: nixos.zip
build:
name: Build and Upload Assets
-10
View File
@@ -1,10 +0,0 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"golang.go",
"esbenp.prettier-vscode",
"svelte.svelte-vscode",
"a-h.templ",
"bradlc.vscode-tailwindcss"
]
}
-34
View File
@@ -1,34 +0,0 @@
# Contributing
## Development
### Architecture
The software suite comprises two main components that are designed to work together on the same network.
The _Display Block_ is responsible for displaying content on a monitor.
The _Control Block_ controls multiple Display Blocks via a user interface. One Display Block can be controlled by multiple Control Blocks.
- `control/`: Go code for the Control Block.
- `frontend/`: The actual frontend written in SvelteKit.
- `display/`: Go code for the Display Block.
- `nixos/`: NixOS configuration for a self-updating, ready-to-use Linux system for the Display Block. This is not necessary for using PLG-MuDiCS.
- `shared/`: Shared code and assets for the Control and Display Block.
### Technologies
- **Go** for the base code in both the Control and Display Block.
- **SvelteKit** in prerender mode for the frontend of the Control Block.
- **TailwindCSS** for styling the frontend of the Control Block.
- **Chromium** browser for handling most media display in the Display Block and serving the frontend of the Control Block.
- **LibreOffice** for handling more complex documents in the Display Block.
### Requirements
- Go v1.25 or higher
- Deno v2
### Running
1. open the wanted folder of the block inside a terminal
2. run `go generate ./..` to generate files required at build time
3. run `go run *.go` to compile and run the block
-661
View File
@@ -1,661 +0,0 @@
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/>.
-17
View File
@@ -1,20 +1,3 @@
# PLG-MuDiCS
![Lines of Code](https://tokei.rs/b1/github/PLG-Development/PLG-MuDiCS?category=code)
Open source solution for advanced remote control and coordination of one or more presentation displays. Specially optimized for fast operation in highly dynamic multi-display situations.
## Display Requirements
- Linux
- Bash
- Chromium
- LibreOffice (optional, presentation view)
- Xreader (optional, pdf view)
- ImageMagick (optional, image preview)
- ffmpeg (optional, video preview)
- Ghostscript (optional, pdf preview)
## Contributing
See [Contributing.md](/CONTRIBUTING.md)
+3
View File
@@ -0,0 +1,3 @@
# PLG-MuDiCS Control
## Development
-3
View File
@@ -21,6 +21,3 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# test
test-results/
-3
View File
@@ -1,3 +0,0 @@
{
"minimumDependencyAge": "P7D"
}
+477 -1075
View File
File diff suppressed because it is too large Load Diff
-39
View File
@@ -1,39 +0,0 @@
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();
});
+50 -1620
View File
File diff suppressed because it is too large Load Diff
+4 -16
View File
@@ -11,29 +11,25 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
"test:e2e": "playwright test",
"test": "deno task test:e2e"
"format": "prettier --write ."
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@playwright/test": "1.56.1",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@tiptap/extension-placeholder": "^3.11.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"sass-embedded": "^1.93.3",
"svelte": "^5.0.0",
"svelte-check": "^4.3.5",
"svelte-check": "^4.0.0",
"svelte-dnd-action": "^0.9.65",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
@@ -42,14 +38,6 @@
},
"dependencies": {
"@thisux/sveltednd": "^0.0.20",
"@tiptap/core": "^3.11.0",
"@tiptap/extension-highlight": "^3.11.0",
"@tiptap/extension-text-style": "^3.11.0",
"@tiptap/extensions": "^3.11.0",
"@tiptap/pm": "^3.11.0",
"@tiptap/starter-kit": "^3.11.0",
"dexie": "^4.2.1",
"lucide-svelte": "^0.545.0",
"svelte-splitpanes": "^8.0.9"
"lucide-svelte": "^0.545.0"
}
}
-10
View File
@@ -1,10 +0,0 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
fullyParallel: true,
webServer: {
command: 'deno task build && deno task preview',
port: 4173
},
testDir: 'e2e'
});
+2 -76
View File
@@ -1,4 +1,6 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
/* Adding more color steps */
@theme {
@@ -11,79 +13,3 @@
--color-stone-750: oklch(0.321 0.0085 50.928);
--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.mudics-stone-theme {
.splitpanes__pane {
@apply bg-stone-900;
}
.splitpanes__splitter {
@apply bg-transparent hover:bg-stone-700 active:bg-stone-600 transition-colors duration-200 shrink-0 relative box-border rounded mx-5;
/* box-sizing: border-box;
position: relative;
flex-shrink: 0; */
&:before,
&:after {
@apply bg-stone-750 transition-colors duration-200 absolute top-[50%] left-[50%] content-[''] rounded;
}
&:hover:before,
&:hover:after {
@apply bg-stone-400;
}
&:active:before,
&:active:after {
@apply bg-stone-300;
}
&:first-child {
/* cursor: auto; */
}
}
}
.mudics-stone-theme {
&.splitpanes .splitpanes .splitpanes__splitter {
@apply z-10;
}
&.splitpanes--horizontal > .splitpanes__splitter,
.splitpanes--horizontal > .splitpanes__splitter {
@apply h-2 cursor-row-resize;
&:before,
&:after {
@apply h-0.5 w-10 translate-x-[-50%];
}
&:before {
@apply mt-[calc(-0.7*var(--spacing))];
}
&:after {
@apply mt-[calc(0.1*var(--spacing))];
}
}
}
@@ -1,6 +1,7 @@
<script lang="ts">
import { get_shifted_color } from '$lib/ts/stores/ui_behavior';
import type { MenuOption } from '$lib/ts/types';
import { flip } from 'svelte/animate';
import { get_shifted_color } from '../ts/stores/ui_behavior';
import type { MenuOption } from '../ts/types';
import { fade } from 'svelte/transition';
let {
@@ -10,12 +11,12 @@
active_bg = get_shifted_color(bg, 200),
disabled = false,
title = '',
click_function = () => {},
menu_options = [],
click_function = (e: MouseEvent) => {},
menu_options = null,
menu_class = 'right-0',
div_class = '',
children
}: {
} = $props<{
className?: string;
bg?: string;
hover_bg?: string;
@@ -23,19 +24,19 @@
disabled?: boolean;
title?: string;
click_function?: (e: MouseEvent) => void;
menu_options?: MenuOption[];
menu_options?: MenuOption[] | null;
menu_class?: string;
div_class?: string;
div_class?: string,
children?: any;
} = $props();
}>();
let menu_shown = $state(false);
let button_element: HTMLButtonElement;
let menu_element: HTMLDivElement | null = $state(null);
let menu_element: HTMLDivElement;
let position_bottom = $state(true);
function onclick(e: MouseEvent) {
if (menu_options.length !== 0) {
if (menu_options !== null) {
if (menu_shown) {
close_menu();
} else {
@@ -46,7 +47,7 @@
}
function getPolygon(): [number, number][] | null {
if (!button_element || !menu_element) return null;
if (!button_element || !button_element) return null;
const b = button_element.getBoundingClientRect();
const m = menu_element.getBoundingClientRect();
@@ -153,7 +154,7 @@
transition:fade={{ duration: 50 }}
class="absolute {position_bottom
? 'top-full'
: 'bottom-full'} {menu_class} z-100 my-1.5 min-w-64 rounded-xl backdrop-blur bg-stone-800/45 border border-stone-400/10 shadow-xl/20 p-2 flex flex-col gap-2 text-stone-200 cursor-auto"
: 'bottom-full'} {menu_class} z-100 my-1.5 min-w-64 rounded-xl backdrop-blur bg-black/20 border border-stone-400/10 shadow-xl/20 p-2 flex flex-col gap-2 text-stone-200 cursor-auto"
onclick={(e) => {
e.stopPropagation();
}}
@@ -165,8 +166,8 @@
? 'text-stone-500 cursor-not-allowed'
: '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"
onclick={async (e) => {
if (option.on_select) await option.on_select();
onclick={(e) => {
if (option.on_select) option.on_select();
close_menu();
}}
>
@@ -0,0 +1,61 @@
<script lang="ts">
import {
ArrowBigLeft,
ArrowBigRight,
ChevronDown,
Keyboard,
Power,
PowerOff,
Presentation,
SquareTerminal,
TextAlignStart,
TrafficCone
} from 'lucide-svelte';
import Button from './Button.svelte';
</script>
<div class="grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl min-w-0">
<div class="text-xl font-bold pl-3 content-center bg-stone-700 rounded-t-2xl truncate min-w-0">
Bildschirme steuern
</div>
<div class="flex flex-col gap-2 p-2 overflow-auto">
<div class="flex flex-row justify-between gap-2">
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 w-70 justify-normal">
<Button title="Vorherige Folie (Pfeil nach Links)" className="px-7"
><ArrowBigLeft /></Button
>
<Button title="Nächste Folie (Pfeil nach Rechts)" className="px-7"
><ArrowBigRight /></Button
>
</div>
<Button className="px-3 flex gap-3 w-70 justify-normal"
><TextAlignStart /> Text anzeigen</Button
>
<Button className="px-3 flex gap-3 w-70 justify-normal"><Presentation />Blackout</Button>
<div class="flex flex-row justify-normal">
<Button className="rounded-r-none pl-3 flex gap-3 grow w-60 justify-normal"
><TrafficCone /> Fallback-Bild anzeigen</Button
>
<Button className="rounded-l-none flex grow-0 w-10"><ChevronDown /></Button>
</div>
<Button className="px-3 flex gap-3 w-70 justify-normal"
><Keyboard /> Tastatur-Inputs durchgeben</Button
>
</div>
<div class="flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2">
<Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal"
><Power /> PC hochfahren</Button
>
<Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal"
><PowerOff /> PC herunterfahren</Button
>
</div>
<Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal"
><SquareTerminal /> Shell-Befehl ausführen</Button
>
</div>
</div>
</div>
</div>
@@ -2,10 +2,10 @@
import { GripHorizontal } from 'lucide-svelte';
import { dragHandle } from 'svelte-dnd-action';
let { bg, className = "" }: {
let { bg, className = "" } = $props<{
bg: string;
className?: string;
} = $props();
}>();
</script>
<div
@@ -0,0 +1,111 @@
<script lang="ts">
import { dragHandleZone, TRIGGERS } from 'svelte-dnd-action';
import { dnd_flip_duration_ms, get_selectable_color_classes, is_display_drag, is_group_drag } from '../ts/stores/ui_behavior';
import { cubicOut } from 'svelte/easing';
import { flip } from 'svelte/animate';
import DisplayObject from './DisplayObject.svelte';
import {
add_empty_display_group,
all_displays_of_group_selected,
remove_empty_display_groups,
select_all_of_group,
set_new_display_group_data
} from '../ts/stores/displays';
import DNDGrip from './DNDGrip.svelte';
import { fade } from 'svelte/transition';
import type { DisplayGroup, MenuOption } from '../ts/types';
import { selected_display_ids } from '../ts/stores/select';
let { display_group, get_display_menu_options } = $props<{
display_group: DisplayGroup;
get_display_menu_options: (display_id: string) => MenuOption[]
}>();
let hovering_selectable = $state(false);
function select_all_of_this_group() {
const new_value = !all_displays_of_group_selected(display_group, $selected_display_ids);
select_all_of_group(display_group, new_value);
}
function handle_consider(e: CustomEvent) {
const { items, info } = e.detail;
if (items.length !== 1 && info.trigger === TRIGGERS.DRAG_STARTED) {
$is_display_drag = true;
add_empty_display_group();
}
set_new_display_group_data(display_group.id, items);
}
function handle_finalize(e: CustomEvent) {
remove_empty_display_groups();
$is_display_drag = false;
set_new_display_group_data(display_group.id, e.detail.items);
}
</script>
<div
transition:fade={{ duration: 100 }}
class="{get_selectable_color_classes(
all_displays_of_group_selected(display_group, $selected_display_ids),
{
bg: true,
hover: hovering_selectable,
active: hovering_selectable,
text: true
},
-150,
-50
)} transition-colors duration-200 rounded-2xl flex flex-row cursor-pointer"
>
<div
class="flex flex-col min-w-0 pl-2 py-2 gap-2 w-full"
use:dragHandleZone={{
items: display_group.data,
type: 'item',
flipDurationMs: dnd_flip_duration_ms,
dropTargetStyle: { outline: 'none' },
}}
onconsider={handle_consider}
onfinalize={handle_finalize}
>
{#each display_group.data as display (display.id)}
<!-- Each Group -->
<section
animate:flip={{ duration: $is_group_drag ? 0 : dnd_flip_duration_ms, easing: cubicOut }}
class="outline-none"
role="figure"
>
<DisplayObject {display} {get_display_menu_options} />
</section>
{/each}
{#if display_group.data.length === 0}
<div class="min-h-10 h-full w-full pl-2 py-2 flex justify-center items-center">
Hier in leere neue Gruppe ablegen
</div>
{/if}
</div>
<div
class="flex items-center justify-center px-2"
onclick={(e) => select_all_of_this_group()}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') select_all_of_this_group();
}}
onmouseenter={() => (hovering_selectable = true)}
onmouseleave={() => (hovering_selectable = false)}
>
<DNDGrip
bg={get_selectable_color_classes(
all_displays_of_group_selected(display_group, $selected_display_ids),
{
bg: true
},
-150
)}
/>
</div>
</div>
@@ -1,51 +1,39 @@
<script lang="ts">
import Button from '$lib/components/Button.svelte';
import Button from './Button.svelte';
import {
current_height,
get_selectable_color_classes,
pinned_display_id
} from '$lib/ts/stores/ui_behavior';
import DNDGrip from '$lib/components/DNDGrip.svelte';
import { Menu, Pin, PinOff, VideoOff } from 'lucide-svelte';
} from '../ts/stores/ui_behavior';
import DNDGrip from './DNDGrip.svelte';
import { Menu, Pencil, Pin, PinOff, Trash2, VideoOff, X } from 'lucide-svelte';
import OnlineState from './OnlineState.svelte';
import type { Display, MenuOption } from '$lib/ts/types';
import { is_selected, select, selected_display_ids } from '$lib/ts/stores/select';
import { screenshot_loop } from '$lib/ts/stores/displays';
import { change_file_path, current_file_path } from '$lib/ts/stores/files';
import type { Display, MenuOption } from '../ts/types';
import { is_selected, select, selected_display_ids } from '../ts/stores/select';
let {
display,
get_display_menu_options,
close_pinned_display
}: {
let { display, get_display_menu_options } = $props<{
display: Display;
get_display_menu_options: (display_id: string) => MenuOption[];
close_pinned_display: () => void;
} = $props();
}>();
let hovering_unselectable = $state(false);
async function onclick(e: Event) {
function onclick(e: Event) {
select(selected_display_ids, display.id);
e.stopPropagation();
select(selected_display_ids, display.id, 'toggle');
// force file view update
await change_file_path($current_file_path);
}
async function on_preview_click(e: MouseEvent) {
function on_preview_click(e: MouseEvent) {
if ($pinned_display_id === display.id) {
close_pinned_display();
$pinned_display_id = null;
} else {
$pinned_display_id = display.id;
}
await screenshot_loop(display.id);
e.stopPropagation();
}
</script>
<div
data-testid="display"
role="button"
tabindex="0"
onkeydown={(e) => {
@@ -62,7 +50,7 @@
<div class="flex flex-row gap-4 min-w-0 flex-1">
<!-- Left Preview Screen -->
<button
class="group relative aspect-video bg-stone-800 h-full rounded-lg overflow-hidden cursor-pointer text-stone-200 transition-colors duration-200"
class="group relative aspect-16/9 bg-stone-800 h-full rounded-lg overflow-hidden cursor-pointer text-stone-200 transition-colors duration-200"
onmouseenter={() => (hovering_unselectable = true)}
onmouseleave={() => (hovering_unselectable = false)}
onclick={on_preview_click}
@@ -72,8 +60,8 @@
<div class="size-[50%]">
<Pin class="size-full" />
</div>
{:else if display.preview.url}
<img src={display.preview.url} alt="preview" class="w-full object-cover bg-black" />
{:else if display.preview_url}
<img src={display.preview_url} alt="preview" class="w-full object-cover bg-black" />
{:else}
<!-- No Signal -->
<div class="size-full bg-black flex justify-center items-center">
@@ -0,0 +1,240 @@
<script lang="ts">
import { fade, scale, slide } from 'svelte/transition';
import {
all_displays_of_group_selected,
displays,
get_display_by_id,
select_all_of_group
} from '../ts/stores/displays';
import {
change_height,
current_height,
dnd_flip_duration_ms,
get_selectable_color_classes,
is_display_drag,
is_group_drag,
next_height_step_size,
pinned_display_id
} from '../ts/stores/ui_behavior';
import { type Display, type DisplayGroup, type MenuOption } from '../ts/types';
import Button from './Button.svelte';
import OnlineState from './OnlineState.svelte';
import { cubicOut } from 'svelte/easing';
import { Menu, Minus, Pencil, PinOff, Plus, Settings, Trash2, VideoOff } from 'lucide-svelte';
import { selected_display_ids } from '../ts/stores/select';
import { dragHandleZone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import DisplayGroupObject from './DisplayGroupObject.svelte';
let { handle_display_deletion, handle_display_editing } = $props<{
handle_display_deletion: (display_id: string) => void;
handle_display_editing: (display_id: string) => void;
}>();
let displays_scroll_box: HTMLElement;
let pinned_display: Display | null = $derived(
get_display_by_id($pinned_display_id || '', $displays)
);
function get_display_menu_options(display_id: string): MenuOption[] {
return [
{
icon: Pencil,
name: 'Bildschirm bearbeiten',
on_select: () => {handle_display_editing(display_id)},
},
{
icon: Trash2,
name: 'Bildschirm löschen',
class: 'text-red-400 hover:text-stone-200 hover:!bg-red-400 active:!bg-red-500',
on_select: () => {handle_display_deletion(display_id)},
}
];
}
function select_all(current_displays: DisplayGroup[], current_selected_display_ids: string[]) {
const new_value = !all_selected(current_displays, current_selected_display_ids);
for (const display_group of current_displays) {
select_all_of_group(display_group, new_value);
}
}
function all_selected(current_displays: DisplayGroup[], current_selected_display_ids: string[]) {
for (const display_group of current_displays) {
if (!all_displays_of_group_selected(display_group, current_selected_display_ids)) {
return false;
}
}
return true;
}
function on_wheel(e: WheelEvent) {
if (!$is_group_drag && !$is_display_drag) return;
if (!displays_scroll_box) return;
// apply custom scroll feature
e.preventDefault();
(displays_scroll_box as HTMLElement).scrollBy?.({
top: e.deltaY,
behavior: 'auto'
});
}
</script>
<svelte:window on:wheel={on_wheel} />
<div class="h-[calc(100dvh-3rem-(6*var(--spacing)))] flex flex-col gap-2">
{#if $pinned_display_id}
<!-- Pinned Item -->
<div in:fade={{ duration: 140 }} out:fade={{ duration: 120 }}>
<div
class="grid grid-rows-[2.5rem_auto] will-change-[height,opacity] overflow-hidden rounded-2xl"
transition:slide={{ duration: 260, easing: cubicOut }}
>
<div class="bg-stone-700 flex justify-between w-full p-1 min-w-0 basis-0 flex-1">
<span
class="text-xl font-bold pl-2 content-center truncate min-w-0"
title={pinned_display?.name}
>
{pinned_display?.name}
</span>
<div class="flex flex-row gap-1">
<OnlineState
selected={false}
status={pinned_display?.status ?? ''}
className="flex items-center px-2"
/>
<Button
className="aspect-square !p-1"
bg="bg-stone-600"
click_function={(e) => {
e.stopPropagation();
}}
menu_options={get_display_menu_options($pinned_display_id)}
>
<Menu />
</Button>
<Button
title="Bildschirm nicht mehr anpinnen"
className="aspect-square !p-1"
bg="bg-stone-600"
click_function={() => {
$pinned_display_id = null;
}}
>
<PinOff />
</Button>
</div>
</div>
<div class="w-full h-[30dvh] bg-stone-800 flex justify-center items-center">
{#if pinned_display?.preview_url}
<img
src={pinned_display.preview_url}
alt="preview"
class="max-h-full max-w-full object-cover bg-black"
/>
{:else}
<div class="size-full bg-black flex justify-center items-center">
<VideoOff class="size-[20%]" />
</div>
{/if}
</div>
</div>
</div>
{/if}
<div class="min-h-0 h-full grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl overflow-hidden">
<!-- Normal Heading Left -->
<div class="bg-stone-700 flex justify-between w-full p-1 gap-2 min-w-0">
<span class="text-xl font-bold pl-2 content-center truncate min-w-0">
Verbundene Bildschirme
</span>
<div class="flex flex-row gap-1">
<button
class="gap-2 min-w-40 px-4 rounded-xl cursor-pointer duration-200 transition-colors {get_selectable_color_classes(
all_selected($displays, $selected_display_ids),
{
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
>
</button>
<div class="flex flex-row">
<Button
title="Bildschirme größer darstellen"
className="aspect-square !p-1 rounded-r-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, 1))}
click_function={() => {
change_height('display', 1);
}}
>
<Plus />
</Button>
<Button
title="Bildschirme kleiner darstellen"
className="aspect-square !p-1 rounded-l-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, -1))}
click_function={() => {
change_height('display', -1);
}}
>
<Minus />
</Button>
</div>
</div>
</div>
<div class="min-h-0 overflow-y-auto" bind:this={displays_scroll_box}>
<div
class="min-h-full p-2 flex flex-col gap-4"
use:dragHandleZone={{
items: $displays,
type: 'group',
flipDurationMs: dnd_flip_duration_ms,
dropFromOthersDisabled: true,
dropTargetStyle: { outline: 'none' }
}}
onconsider={(e: CustomEvent) => {
$is_group_drag = true;
$displays = e.detail.items;
}}
onfinalize={(e: CustomEvent) => {
$displays = e.detail.items;
$is_group_drag = false;
}}
>
{#if $displays.length === 1 && $displays[0].data.length === 0}
<div class="text-stone-500 px-10 py-6 leading-relaxed text-center">
Es wurden noch keine Bildschirme hinzugefügt. Klicke oben rechts auf <Settings
class="inline pb-1"
/> und "Neuen Bildschirm hinzufügen".
</div>
{:else}
{#each $displays as display_group (display_group.id)}
<!-- Each Group -->
<section
out:scale={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
animate:flip={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
class="outline-none"
>
<DisplayGroupObject {display_group} {get_display_menu_options} />
</section>
{/each}
{/if}
</div>
</div>
</div>
</div>
@@ -0,0 +1,106 @@
<script lang="ts">
import { ClipboardPaste, Download, FolderPlus, Info, Minus, Pen, Plus, RefreshCcw, Scissors, Trash2, Upload } from "lucide-svelte";
import { change_height, current_height, next_height_step_size } from "../ts/stores/ui_behavior";
import Button from "./Button.svelte";
import PathBar from "./PathBar.svelte";
import { selected_display_ids, selected_file_ids } from "../ts/stores/select";
import { all_files, current_file_path, get_current_folder_elements } from "../ts/stores/files";
import { slide } from "svelte/transition";
import FolderElementObject from "./FolderElementObject.svelte";
import PopUp from "./PopUp.svelte";
</script>
<div class="bg-stone-800 h-full rounded-2xl grid grid-rows-[2.5rem_1fr] min-h-0">
<div class="bg-stone-700 flex justify-between w-full p-1 rounded-t-2xl min-w-0 gap-2">
<span class="text-xl font-bold pl-2 content-center truncate min-w-0">
Dateien anzeigen und verwalten
</span>
<div class="flex flex-ro">
<Button
title="Dateien größer darstellen"
className="aspect-square !p-1 rounded-r-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('file', $current_height, 1))}
click_function={() => {
change_height('file', 1);
}}
>
<Plus />
</Button>
<Button
title="Dateien kleiner darstellen"
className="aspect-square !p-1 rounded-l-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('file', $current_height, -1))}
click_function={() => {
change_height('file', -1);
}}
>
<Minus />
</Button>
</div>
</div>
<div class="flex flex-col gap-2 p-2 overflow-hidden relative rounded-b-2xl">
<div class="flex flex-col gap-2 p-2 bg-stone-750 rounded-xl">
<PathBar />
<div class="flex flex-row justify-between gap-6 overflow-x-auto">
<div class="flex flex-row gap-2 shrink-0">
<Button
title="Neuen Ordner erstellen (Neuen Ordner mit ausgewählten Objekten erstellen)"
className="px-3 flex"><FolderPlus /></Button
>
<div class="border border-stone-700 my-1"></div>
<Button title="Datei(en) hochladen" className="px-3 flex"><Upload /></Button>
<Button
title="Ausgewählte Datei(en) herunterladen"
className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Download /></Button
>
<div class="border border-stone-700 my-1"></div>
<Button
title="Aktuellen Ordner / Ausgewählte Datei(en) zwischen Bildschirmen synchronisieren"
className="px-3 flex gap-3"
><RefreshCcw />
<span class="hidden 2xl:flex">Synchronisieren</span>
</Button>
</div>
<div class="flex flex-row gap-2">
<Button
title="Ausgewählte Datei(en) ausschneiden"
className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Scissors /></Button
>
<Button title="Ausgewählte Datei(en) einfügen" className="px-3 flex"
><ClipboardPaste /></Button
>
<div class="border border-stone-700 my-1"></div>
<Button
title="Ausgewählte Datei umbenennen"
className="px-3 flex"
disabled={$selected_file_ids.length !== 1}><Pen /></Button
>
<Button
title="Ausgewählte Datei(en) löschen"
hover_bg="bg-red-400"
active_bg="bg-red-500"
className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Trash2 /></Button
>
</div>
</div>
</div>
<div class="min-h-0 h-full overflow-y-auto bg-stone-750 rounded-xl">
<div class="flex flex-col gap-2 p-2 min-h-0">
{#each get_current_folder_elements($all_files, $current_file_path, $selected_display_ids) as folder_element (folder_element.id)}
<section in:slide={{ duration: 100 }} class="outline-none">
<FolderElementObject file={folder_element} />
</section>
{/each}
</div>
</div>
<!-- <PopUp title="Test" title_icon={Info}>
<div>Hier kann beliebiges HTML übergeben werden!</div>
</PopUp> -->
</div>
</div>
@@ -0,0 +1,250 @@
<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_types, 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 } from '../ts/utils';
import { open_file } from '../ts/api_handler';
import { displays, get_display_by_id, update_screenshot } from '../ts/stores/displays';
let { file } = $props<{ file: FolderElement }>();
const is_folder = file.type === 'inode/directory';
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;
}
function get_created_string(date_object: Date, full_string = false) {
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 {
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 {
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) {
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;
for (const display_id of $selected_display_ids) {
const ip = get_display_by_id(display_id, $displays)?.ip ?? null;
if (ip) {
await open_file(ip, path_to_file);
await update_screenshot(display_id);
}
}
}
}
</script>
<div class="flex flex-row h-{$current_height.file} w-full">
<div class="h-{$current_height.file} aspect-square max-w-15 flex">
<Button
disabled={!is_folder && get_file_type(file) === null}
title={!is_folder && get_file_type(file) === null ? 'Dateityp nicht unterstützt' : ''}
className="flex rounded-l-lg rounded-r-none {is_folder
? 'text-stone-450'
: 'text-stone-800'} w-full"
div_class="w-full"
bg={get_selectable_color_classes(
!is_folder && get_file_type(file) !== null,
{
bg: true
},
-50
)}
hover_bg={get_selectable_color_classes(
!is_folder,
{
bg: true
},
50
)}
active_bg={get_selectable_color_classes(
!is_folder,
{
bg: true
},
100
)}
click_function={(e) => {
open();
e.stopPropagation();
}}
>
{#if is_folder}
<ArrowRight class="size-full" strokeWidth="3" />
{:else if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[0].length !== 0}
<RefreshPlay className="size-full" />
{:else if get_file_type(file) !== null}
<Play class="size-full" strokeWidth="3" />
{:else}
<Ban class="size-full" strokeWidth="3" />
{/if}
</Button>
</div>
<div
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick(e);
}}
{onclick}
class="{get_selectable_color_classes(is_selected(file.id, $selected_file_ids), {
bg: true,
hover: true,
active: true,
text: true
})} rounded-r-lg transition-colors duration-200 gap-4 flex flex-row justify-between cursor-pointer group w-full min-w-0"
>
<div class="flex flex-row gap-2 min-w-0 w-full">
<div class="aspect-square rounded-md flex justify-center items-center p-2">
{#if is_folder}
<Folder class="size-full" />
{:else if file.thumbnail}
<div></div>
{:else if get_file_type(file)?.icon}
{@const Icon = get_file_type(file)?.icon}
<Icon class="size-full" />
{:else}
<FileIcon />
{/if}
</div>
<div class="content-center truncate select-none w-full" title={file.name}>
{file.name.includes('.') ? 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>
@@ -0,0 +1,48 @@
<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,16 +1,11 @@
<script lang="ts">
import { type DisplayStatus } from '$lib/ts/types';
import { display_status_to_info } from '$lib/ts/utils';
import { display_status_to_info, type DisplayStatus } from "../ts/types";
let {
selected,
status,
className = ''
}: {
let { selected, status, className = "" } = $props<{
selected: boolean;
status: DisplayStatus;
className?: string;
} = $props();
}>();
function get_text_color(selected: boolean, status: DisplayStatus) {
switch (status) {
@@ -1,17 +1,16 @@
<script lang="ts">
import { ChevronRight, House } from 'lucide-svelte';
import Button from '$lib/components/Button.svelte';
import Button from './Button.svelte';
import { onDestroy, onMount } from 'svelte';
import type { MenuOption } from '$lib/ts/types';
import { change_file_path, current_file_path } from '$lib/ts/stores/files';
import type { MenuOption } from '../ts/types';
import { change_file_path, current_file_path } from '../ts/stores/files';
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { cubicOut } from 'svelte/easing';
let {
bg = 'bg-stone-700'
}: {
let { bg = 'bg-stone-700' } = $props<{
bg?: string;
} = $props();
}>();
let outside_container: HTMLDivElement;
let inside_container: HTMLDivElement;
@@ -80,21 +79,21 @@
out.push({
name: '  '.repeat(i) + hidden_folders[i],
class: 'truncate max-w-80',
on_select: async () => {
await open_path(i + 1, path);
on_select: () => {
open_path(i + 1, path);
}
});
}
return out;
}
async function open_path(index_of_all_folders: number, path: string) {
function open_path(index_of_all_folders: number, path: string) {
let new_path = '/';
const all_folders = get_folders(path);
for (let i = 0; i < index_of_all_folders; i++) {
new_path += all_folders[i] + '/';
}
await change_file_path(new_path);
change_file_path(new_path);
}
onMount(() => {
@@ -114,14 +113,11 @@
<Button
className="py-1 shrink-0 grow-0 w-10"
{bg}
click_function={async () => {
await open_path(0, $current_file_path);
click_function={(e) => {
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>
</div>
{#if cut_folders !== 0}
@@ -148,8 +144,8 @@
? 'max-w-80 font-bold'
: 'max-w-30'}"
{bg}
click_function={async () => {
await open_path(cut_folders + i + 1, $current_file_path);
click_function={(e) => {
open_path(cut_folders + i + 1, $current_file_path);
}}
>
<ChevronRight class="shrink-0 text-stone-500 h-full" />
@@ -0,0 +1,69 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { onDestroy, onMount } from 'svelte';
import Button from './Button.svelte';
import type { PopupContent } from '../ts/types';
import { fade } from 'svelte/transition';
let { content, close_function } = $props<{
content: PopupContent;
close_function: () => void;
}>();
function try_to_close() {
if (!content.closable || !content.open) return;
close_function();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
try_to_close();
}
}
onMount(() => {
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
</script>
{#if content.open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="absolute inset-0 backdrop-blur bg-white/10 flex justify-center items-center"
onclick={try_to_close}
transition:fade={{ duration: 100 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="bg-stone-800 rounded-2xl min-w-[30%] max-w-[80%] max-h-[80%] flex flex-col shadow-2xl/60 overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<div
class="text-2xl font-bold bg-stone-700 p-1.5 flex flex-row justify-between gap-6 w-full"
>
<div class="flex flex-row flex-1 gap-4 pl-2 py-1 items-center grow whitespace-nowrap min-w-0 flex-shrink-0 {content.title_class ?? ''}">
{#if content.title_icon}
{@const Icon = content.title_icon}
<Icon strokeWidth="2.8" class="flex-shrink-0" />
{/if}
<div class="flex-shrink-0">
{content.title}
</div>
</div>
<div class="flex aspect-square flex-shrink-0">
{#if content.closable}
<Button className="aspect-square !p-1.5" click_function={try_to_close}>
<X />
</Button>
{/if}
</div>
</div>
<div class="p-2 min-h-0 overflow-auto flex flex-col gap-2">
{@render content.snippet(content.snippet_arg)}
</div>
</div>
</div>
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
let { className = '' }: { className?: string } = $props();
let {className = ''} = $props<{className?: string}>();
</script>
<svg
@@ -0,0 +1,70 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { get_shifted_color } from '../ts/stores/ui_behavior';
import { onMount } from 'svelte';
let {
current_value = $bindable(),
current_valid = $bindable(),
className = '',
bg = 'bg-stone-750',
title,
placeholder = '',
is_valid_function = null
} = $props<{
current_value: string;
current_valid: boolean;
className?: string;
bg?: string;
title: string;
placeholder?: string;
is_valid_function?: ((input: string) => [boolean, string]) | null;
}>();
let focus_bg = get_shifted_color(bg, 100);
let focussed = $state(false);
let current_info = $state('');
function validate_input() {
if (!is_valid_function) return;
[current_valid, current_info] = is_valid_function(current_value.trim());
}
function get_highlighting_string(): string {
if (!is_valid_function) return '';
if (current_valid) {
return 'focus:inset-ring-2 focus:inset-ring-green-400';
} else {
return 'inset-ring-2 inset-ring-red-400';
}
}
onMount(() => {
validate_input();
});
</script>
<div class="flex flex-col {className}">
<div class="flex flex-row justify-between text-sm px-1">
<div class="text-stone-400">
{title}:
</div>
{#if is_valid_function && focussed}
<div class={current_valid ? "text-green-400" : "text-red-400"} transition:fade={{ duration: 100 }}>
{current_info}
</div>
{/if}
</div>
<input
bind:value={current_value}
type="text"
oninput={validate_input}
onfocus={() => {
focussed = true;
}}
onfocusout={() => {
focussed = false;
}}
class="{bg} focus:{focus_bg} outline-none py-2 px-3 rounded-xl transition-all duration-100 {get_highlighting_string()}"
{placeholder}
/>
</div>
@@ -1,126 +0,0 @@
<script lang="ts">
import { dragHandleZone, TRIGGERS } from 'svelte-dnd-action';
import {
dnd_flip_duration_ms,
get_selectable_color_classes,
is_display_drag,
is_group_drag
} from '$lib/ts/stores/ui_behavior';
import { cubicOut } from 'svelte/easing';
import { flip } from 'svelte/animate';
import DisplayObject from './DisplayObject.svelte';
import {
all_displays_of_group_selected,
get_display_ids_in_group,
set_select_for_group,
set_new_display_order
} from '$lib/ts/stores/displays';
import DNDGrip from '$lib/components/DNDGrip.svelte';
import { fade } from 'svelte/transition';
import type { MenuOption } from '$lib/ts/types';
import { selected_display_ids } from '$lib/ts/stores/select';
import { liveQuery, type Observable } from 'dexie';
let {
display_group_id,
get_display_menu_options,
close_pinned_display
}: {
display_group_id: string;
get_display_menu_options: (display_id: string) => MenuOption[];
close_pinned_display: () => void;
} = $props();
let all_selected: 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);
async function select_all_of_this_group() {
const new_value = !($all_selected || false);
await set_select_for_group(display_group_id, new_value);
}
async function handle_consider(e: CustomEvent) {
const { items, info } = e.detail;
if (items.length !== 1 && info.trigger === TRIGGERS.DRAG_STARTED) {
$is_display_drag = true;
// add_empty_display_group();
}
await set_new_display_order(items);
}
async function handle_finalize(e: CustomEvent) {
$is_display_drag = false;
await set_new_display_order(e.detail.items);
}
</script>
<div
transition:fade={{ duration: 100 }}
class="{get_selectable_color_classes(
$all_selected || false,
{
bg: true,
hover: hovering_selectable,
active: hovering_selectable,
text: true
},
-150,
-50
)} transition-colors duration-200 rounded-2xl flex flex-row cursor-pointer"
>
<div
class="flex flex-col min-w-0 pl-2 py-2 gap-2 w-full"
use:dragHandleZone={{
items: $display_ids_in_group || [],
type: 'item',
flipDurationMs: dnd_flip_duration_ms,
dropTargetStyle: { outline: 'none' }
}}
onconsider={handle_consider}
onfinalize={handle_finalize}
>
{#each $display_ids_in_group || [] as display (display.id)}
<!-- Each Group -->
<section
animate:flip={{ duration: $is_group_drag ? 0 : dnd_flip_duration_ms, easing: cubicOut }}
class="outline-none"
role="figure"
>
<DisplayObject {display} {get_display_menu_options} {close_pinned_display} />
</section>
{/each}
{#if ($display_ids_in_group || []).length === 0}
<div class="min-h-10 h-full w-full pl-2 py-2 flex justify-center items-center">
Hier in leere neue Gruppe ablegen
</div>
{/if}
</div>
<div
class="flex items-center justify-center px-2"
onclick={select_all_of_this_group}
role="button"
tabindex="0"
onkeydown={async (e) => {
if (e.key === 'Enter' || e.key === ' ') await select_all_of_this_group();
}}
onmouseenter={() => (hovering_selectable = true)}
onmouseleave={() => (hovering_selectable = false)}
>
<DNDGrip
bg={get_selectable_color_classes(
$all_selected || false,
{
bg: true
},
-150
)}
/>
</div>
</div>
@@ -1,19 +0,0 @@
<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>
@@ -1,399 +0,0 @@
<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,80 +0,0 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import Button from './Button.svelte';
import type { PopupContent } from '$lib/ts/types';
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
let {
content,
close_function,
className = '',
snippet_container_class = ''
}: {
content: PopupContent;
close_function: () => void;
className?: string;
snippet_container_class?: string;
} = $props();
function try_to_close() {
if (!content.closable || !content.open) return;
close_function();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
try_to_close();
}
}
onMount(() => {
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
</script>
{#if content.open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="absolute inset-0 backdrop-blur flex justify-center items-center z-50 {className}"
onclick={try_to_close}
transition:fade={{ duration: 100 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="bg-stone-800 rounded-2xl min-w-[30%] max-w-[90%] max-h-[85%] flex flex-col shadow-2xl/60 overflow-hidden {content.window_class ??
''}"
onclick={(e) => e.stopPropagation()}
>
{#if content.title}
<div class="font-bold bg-stone-700 p-1.5 flex flex-row justify-between gap-8 w-full">
<div
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}
{@const Icon = content.title_icon}
<Icon strokeWidth="2" class="shrink-0" />
{/if}
<div class="shrink-0">
{content.title}
</div>
</div>
<div class="flex aspect-square shrink-0">
{#if content.closable}
<Button className="aspect-square p-1.5!" click_function={try_to_close}>
<X />
</Button>
{/if}
</div>
</div>
{/if}
<div class="p-2 min-h-0 overflow-auto flex flex-col gap-2 {snippet_container_class}">
{@render content.snippet(content.snippet_arg)}
</div>
</div>
</div>
{/if}
@@ -1,134 +0,0 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { get_shifted_color } from '$lib/ts/stores/ui_behavior';
import { onMount } from 'svelte';
import { selected_display_ids } from '$lib/ts/stores/select';
let {
current_value = $bindable(),
current_valid = $bindable(),
className = '',
bg = 'bg-stone-750',
title,
placeholder = '',
is_valid_function = null,
focused_on_start = false,
extension = null,
enter_mode = 'none',
enter_function = null
}: {
current_value: string;
current_valid: boolean;
className?: string;
bg?: string;
title: string;
placeholder?: string;
is_valid_function?: ((input: string) => [boolean, string] | Promise<[boolean, string]>) | null;
focused_on_start?: boolean;
extension?: string | null;
enter_mode?: 'none' | 'focus_next' | 'submit';
enter_function?: (() => void) | null;
} = $props();
let focus_bg = get_shifted_color(bg, 100);
let focused: boolean = $state(false);
let current_info = $state('');
let input_element: HTMLInputElement;
async function validate_input() {
if (!is_valid_function) return;
[current_valid, current_info] = await is_valid_function(current_value.trim());
}
function get_highlighting_string(): string {
if (!is_valid_function) return '';
if (current_valid) {
return 'focus-within:inset-ring-2 focus-within:inset-ring-green-400';
} else {
return 'inset-ring-2 inset-ring-red-400';
}
}
function focus_next_element() {
const focusable = Array.from(document.querySelectorAll<HTMLElement>('input')).filter(
(el) => !el.hasAttribute('disabled')
);
const index = focusable.indexOf(input_element);
if (index !== -1 && index < focusable.length - 1) {
focusable[index + 1].focus();
}
}
function handle_keydown(event: KeyboardEvent) {
if (event.key !== 'Enter') return;
if (enter_mode === 'focus_next') {
event.preventDefault();
focus_next_element();
} else if (enter_mode === 'submit' && enter_function) {
event.preventDefault();
enter_function();
}
}
onMount(async () => {
await validate_input();
if (focused_on_start && input_element) input_element.focus();
selected_display_ids.subscribe(async () => {
await validate_input();
});
});
</script>
<div class="flex flex-col {className} relative">
<div class="flex flex-row justify-between text-sm px-1 gap-4">
<div class="text-stone-400">
{title}:
</div>
{#if is_valid_function && focused}
<div
class={current_valid ? 'text-green-400' : 'text-red-400'}
transition:fade={{ duration: 100 }}
>
{current_info}
</div>
{/if}
</div>
<!-- <input
bind:this={input_element}
bind:value={current_value}
bind:focused
onkeydown={handle_keydown}
type="text"
oninput={validate_input}
class="{bg} focus:{focus_bg} outline-none py-2 px-3 rounded-xl transition-all duration-100 {get_highlighting_string()}"
{placeholder}
/> -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
onclick={() => {
input_element.focus();
}}
class="{bg} focus-within:{focus_bg} flex items-center rounded-xl {get_highlighting_string()} cursor-text"
>
<input
bind:this={input_element}
bind:value={current_value}
bind:focused
onkeydown={handle_keydown}
oninput={validate_input}
type="text"
class=" outline-none py-2 px-3 transition-all duration-100 grow group"
{placeholder}
/>
{#if extension}
<span class="pr-3 text-stone-400 select-none pointer-events-none">
{extension}
</span>
{/if}
</div>
</div>
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
-306
View File
@@ -1,306 +0,0 @@
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
@@ -1,47 +0,0 @@
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();
@@ -1,401 +0,0 @@
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
@@ -1,34 +0,0 @@
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);
}
@@ -1,197 +0,0 @@
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
@@ -1,410 +0,0 @@
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,26 +0,0 @@
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);
}
@@ -1,45 +0,0 @@
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
@@ -1,134 +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?: 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
@@ -1,115 +0,0 @@
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">
import '../app.css';
import Notification from './Notification.svelte';
import Notification from '../components/Notification.svelte';
let { children } = $props();
</script>
-1
View File
@@ -1,2 +1 @@
export const prerender = true;
export const ssr = false;
+43 -56
View File
@@ -1,25 +1,26 @@
<script lang="ts">
import { Monitor, Plus, Radio, Settings, Trash2, X } from 'lucide-svelte';
import Button from '$lib/components/Button.svelte';
import FileView from './FileView.svelte';
import ControlView from './ControlView.svelte';
import DisplayView from './DisplayView.svelte';
import PopUp from '$lib/components/PopUp.svelte';
import { type PopupContent } from '$lib/ts/types';
import TextInput from '$lib/components/TextInput.svelte';
import Button from '../components/Button.svelte';
import FileView from '../components/FileView.svelte';
import ControlView from '../components/ControlView.svelte';
import DisplayView from '../components/DisplayView.svelte';
import SplashScreen from './../../../../shared/splash_screen.html?raw';
import PopUp from '../components/PopUp.svelte';
import { display_status_to_info, type PopupContent } from '../ts/types';
import TextInput from '../components/TextInput.svelte';
import {
add_display,
displays,
edit_display_data,
get_display_by_id,
is_display_name_taken,
remove_display
} from '$lib/ts/stores/displays';
import { notifications } from '$lib/ts/stores/notification';
import { ping_ip } from '$lib/ts/api_handler';
} from '../ts/stores/displays';
import { text } from '@sveltejs/kit';
import { notifications } from '../ts/stores/notification';
import { ping_ip } from '../ts/api_handler';
import { onMount } from 'svelte';
import { on_start } from '$lib/ts/main';
import { display_status_to_info } from '$lib/ts/utils';
import HighlightedText from '$lib/components/HighlightedText.svelte';
import { on_start } from '../ts/main';
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))$/;
@@ -29,10 +30,8 @@
open: false,
snippet: null,
title: '',
title_class: '!text-xl',
closable: true
});
let remove_display_name = $state('');
const text_inputs_valid_null_values = {
name: { valid: false, value: '' },
@@ -51,16 +50,16 @@
}
async function finalize_add_edit_display(existing_display_id: string | null) {
popup_close_function();
const ip = text_inputs_valid.ip.value;
const mac = text_inputs_valid.mac.value === '' ? null : text_inputs_valid.mac.value;
const name = text_inputs_valid.name.value;
if (!!existing_display_id) {
await edit_display_data(existing_display_id, ip, mac, name);
edit_display_data(existing_display_id, ip, mac, name);
} else {
const status = await ping_ip(text_inputs_valid.ip.value);
await add_display(ip, mac, name, status);
add_display(ip, mac, name, status);
}
popup_close_function();
}
function popup_close_function() {
@@ -71,29 +70,27 @@
text_inputs_valid = text_inputs_valid_null_values;
popup_content = {
open: true,
snippet: display_popup,
snippet: add_new_display_popup,
title: 'Neuen Bildschirm hinzufügen',
title_icon: Monitor,
title_class: '!text-xl',
closable: true
};
};
const show_remove_display_popup = async (display_id: string) => {
remove_display_name = (await get_display_by_id(display_id))?.name || '?';
const show_remove_display_popup = (display_id: string) => {
popup_content = {
open: true,
snippet: remove_display_popup,
snippet_arg: display_id,
title: 'Bildschirm wirklich löschen?',
title_class: 'text-red-400 !text-xl',
title_class: 'text-red-400',
title_icon: Trash2,
closable: true
};
};
const show_edit_display_popup = async (display_id: string) => {
const display = await get_display_by_id(display_id);
const show_edit_display_popup = (display_id: string) => {
const display = get_display_by_id(display_id, $displays);
if (!display) return;
// insert existing values in text_inputs_valid
for (const key of Object.keys(text_inputs_valid) as (keyof typeof text_inputs_valid)[]) {
@@ -102,55 +99,54 @@
}
popup_content = {
open: true,
snippet: display_popup,
snippet: add_new_display_popup,
snippet_arg: display_id,
title: 'Bildschirm bearbeiten',
title_icon: Monitor,
title_class: '!text-xl',
closable: true
};
};
onMount(on_start);
onMount(() => {
on_start();
});
</script>
{#snippet remove_display_popup(display_id: string)}
<div class="max-w-prose px-2">
Soll der Bildschirm <HighlightedText>{remove_display_name}</HighlightedText> wirklich gelöscht werden?
Dadurch wird es von diesem Controller nicht mehr erreichbar. Die Installation auf dem Gerät bleibt
bestehen. Mit dem erneuten Hinzufügen des Bildschirms wird er wieder steuerbar.
Soll der Bildschirm "{get_display_by_id(display_id, $displays)?.name || '?'}" wirklich gelöscht
werden? Dadurch wird es von diesem Controller nicht mehr erreichbar. Die Installation auf dem
Gerät bleibt bestehen. Mit dem erneuten Hinzufügen des Bildschirms wird er wieder steuerbar.
</div>
<div class="flex flex-row justify-end gap-2">
<Button className="button space font-bold" click_function={popup_close_function}>
Abbrechen
</Button>
<Button className="px-4 font-bold" click_function={popup_close_function}>Abbrechen</Button>
<Button
className="error button space"
click_function={async () => {
hover_bg="bg-red-400"
active_bg="bg-red-500"
className="px-4 flex text-red-400 hover:text-stone-100"
click_function={() => {
remove_display(display_id);
popup_close_function();
await remove_display(display_id);
}}>Löschen</Button
>
</div>
{/snippet}
{#snippet display_popup(existing_display_id: string | null = null)}
{#snippet add_new_display_popup(existing_display_id: string | null = null)}
<TextInput
focused_on_start
bind:current_value={text_inputs_valid.name.value}
bind:current_valid={text_inputs_valid.name.valid}
title="Anzeigename"
placeholder="z.B. Beamer vorne links"
is_valid_function={async (input: string) => {
is_valid_function={(input: string) => {
if (!!existing_display_id) {
if (input === (await get_display_by_id(existing_display_id))?.name)
if (input === get_display_by_id(existing_display_id, $displays)?.name)
return [true, 'Gültiger Name'];
}
if (input.length === 0 || input.length > 50) return [false, 'Ungültige Länge'];
if (await is_display_name_taken(input)) return [false, 'Name bereits verwendet'];
if (is_display_name_taken(input)) return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
enter_mode="focus_next"
/>
<div class="flex flex-row gap-2">
<TextInput
@@ -164,7 +160,6 @@
: [false, 'Ungültige IP-Adresse'];
}}
className="grow"
enter_mode="focus_next"
/>
<div class="flex items-end shrink-0">
<Button
@@ -194,10 +189,6 @@
? [true, 'Gültige MAC-Adresse']
: [false, 'Ungültige MAC-Adresse'];
}}
enter_mode="submit"
enter_function={async () => {
await finalize_add_edit_display(existing_display_id);
}}
/>
<div class="flex flex-row gap-2 justify-end pt-2">
{#if !!existing_display_id}
@@ -208,8 +199,8 @@
disabled={!all_text_inputs_valid()}
className="{!!existing_display_id ? 'px-4' : 'pl-3 pr-4 gap-2'} font-bold"
bg="bg-stone-650"
click_function={async () => {
await finalize_add_edit_display(existing_display_id);
click_function={() => {
finalize_add_edit_display(existing_display_id);
}}
>{#if !!existing_display_id}
Speichern
@@ -227,6 +218,7 @@
<span class="text-4xl font-bold content-center pl-1"> PLG MuDiCS </span>
<Button
className="aspect-square"
bg="bg-stone-800"
div_class="aspect-square"
menu_options={[
{
@@ -255,10 +247,5 @@
<FileView />
</div>
</div>
<PopUp
content={popup_content}
close_function={popup_close_function}
className="bg-white/10"
snippet_container_class="min-w-115"
/>
<PopUp content={popup_content} close_function={popup_close_function} />
</main>
@@ -1,225 +0,0 @@
<script lang="ts">
import {
ArrowBigLeft,
ArrowBigRight,
ChevronDown,
Keyboard,
Power,
PowerOff,
Presentation,
SquareTerminal,
TextAlignStart,
TrafficCone
} from 'lucide-svelte';
import Button from '$lib/components/Button.svelte';
import PopUp from '$lib/components/PopUp.svelte';
import type { PopupContent } from '$lib/ts/types';
import KeyInput from './KeyInput.svelte';
import { send_keyboard_input, show_blackscreen, shutdown, startup } from '$lib/ts/api_handler';
import { get_display_by_id, run_on_all_selected_displays } from '$lib/ts/stores/displays';
import { selected_display_ids } from '$lib/ts/stores/select';
import TipTapInput from './TipTapInput.svelte';
import { db } from '$lib/ts/database';
let popup_content: PopupContent = $state({
open: false,
snippet: null,
title: '',
closable: true
});
function popup_close_function() {
popup_content.open = false;
}
const show_send_keys_popup = () => {
popup_content = {
open: true,
snippet: send_keys_popup,
title: 'Tastatur-Eingaben durchgeben',
title_icon: Keyboard,
closable: true
};
};
const show_text_popup = () => {
popup_content = {
open: true,
snippet: text_popup,
title: 'Text anzeigen',
title_icon: TextAlignStart,
closable: true,
window_class: 'size-full'
};
};
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>
{#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()}
<div class="overflow-hidden flex flex-col gap-2">
<div>
<KeyInput />
</div>
<div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={popup_close_function}>Fertig</Button>
</div>
</div>
{/snippet}
{#snippet text_popup()}
<TipTapInput />
{/snippet}
<div class="grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl min-w-0">
<div class="text-xl font-bold pl-3 content-center bg-stone-700 rounded-t-2xl truncate min-w-0">
Bildschirme steuern
</div>
<div class="relative flex flex-col gap-2 p-2 overflow-auto">
<div class="flex flex-row justify-between gap-2">
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 w-75 justify-normal">
<Button
title="Vorherige Folie (Pfeil nach Links)"
className="px-9"
disabled={$selected_display_ids.length === 0}
click_function={async () => {
await send_single_key_press('VK_LEFT');
}}><ArrowBigLeft /></Button
>
<Button
title="Nächste Folie (Pfeil nach Rechts)"
className="px-9"
disabled={$selected_display_ids.length === 0}
click_function={async () => {
await send_single_key_press('VK_RIGHT');
}}><ArrowBigRight /></Button
>
</div>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={$selected_display_ids.length === 0}
click_function={show_text_popup}><TextAlignStart /> Text anzeigen</Button
>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={$selected_display_ids.length === 0}
click_function={async () => {
await run_on_all_selected_displays((d) => show_blackscreen(d.ip));
}}><Presentation />Blackout</Button
>
<div class="flex flex-row justify-normal">
<Button
className="rounded-r-none pl-3 flex gap-3 grow w-65 justify-normal"
disabled={$selected_display_ids.length === 0}
><TrafficCone /> Fallback-Bild anzeigen</Button
>
<Button className="rounded-l-none flex grow-0 w-10"><ChevronDown /></Button>
</div>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={$selected_display_ids.length === 0}
click_function={show_send_keys_popup}><Keyboard /> Tastatur-Eingaben durchgeben</Button
>
</div>
<div class="flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2">
{#await all_state($selected_display_ids) then all}
<Button
className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
disabled={all === 'on' || $selected_display_ids.length === 0}
click_function={startup_action}
>
<Power /> PC hochfahren
</Button>
<Button
className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
disabled={all === 'off' || $selected_display_ids.length === 0}
click_function={ask_shutdonw}
>
<PowerOff /> PC herunterfahren</Button
>
{/await}
</div>
<Button
className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
disabled={$selected_display_ids.length === 0}
><SquareTerminal /> Shell-Befehl ausführen</Button
>
</div>
</div>
<PopUp
content={popup_content}
close_function={popup_close_function}
className="rounded-b-2xl"
snippet_container_class="size-full"
/>
</div>
</div>
@@ -1,286 +0,0 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import {
all_displays_of_group_selected,
get_display_by_id,
get_display_groups,
set_select_for_group,
set_new_display_group_order
} from '$lib/ts/stores/displays';
import {
change_height,
current_height,
dnd_flip_duration_ms,
is_display_drag,
is_group_drag,
next_height_step_size,
pinned_display_id
} from '$lib/ts/stores/ui_behavior';
import { type Display, type DisplayGroup, type MenuOption } from '$lib/ts/types';
import Button from '$lib/components/Button.svelte';
import OnlineState from '../lib/components/OnlineState.svelte';
import { cubicOut } from 'svelte/easing';
import { Menu, Pencil, PinOff, Settings, Trash2, VideoOff, ZoomIn, ZoomOut } from 'lucide-svelte';
import { selected_display_ids } from '$lib/ts/stores/select';
import { dragHandleZone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import DisplayGroupObject from '../lib/components/DisplayGroupObject.svelte';
import { Pane, Splitpanes } from 'svelte-splitpanes';
import HighlightedText from '$lib/components/HighlightedText.svelte';
import { liveQuery, type Observable } from 'dexie';
let {
handle_display_deletion,
handle_display_editing
}: {
handle_display_deletion: (display_id: string) => void;
handle_display_editing: (display_id: string) => void;
} = $props();
let displays_scroll_box: HTMLElement;
let pinned_display: Observable<Display | null> | undefined = $state();
$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 pinned_pane_size: number = $state(last_pinned_pane_size);
function close_pinned_display() {
last_pinned_pane_size = pinned_pane_size;
pinned_pane_size = 0;
}
function get_display_menu_options(display_id: string): MenuOption[] {
return [
{
icon: Pencil,
name: 'Bildschirm bearbeiten',
on_select: () => {
handle_display_editing(display_id);
}
},
{
icon: Trash2,
name: 'Bildschirm löschen',
class: 'text-red-400 hover:text-stone-200 hover:!bg-red-400 active:!bg-red-500',
on_select: () => {
handle_display_deletion(display_id);
}
}
];
}
async function toggle_all_selected_displays(current_displays: DisplayGroup[]) {
const new_value = !$all_groups_selected;
for (const display_group of current_displays) {
await set_select_for_group(display_group.id, new_value);
}
}
async function all_selected(
current_displays: DisplayGroup[],
current_selected_display_ids: string[]
) {
for (const display_group of current_displays) {
if (!(await all_displays_of_group_selected(display_group.id, current_selected_display_ids))) {
return false;
}
}
return true;
}
function handle_splitpane_resize(e: any) {
if (e.detail[0].size === 0) {
$pinned_display_id = null;
pinned_pane_size = last_pinned_pane_size;
}
}
function on_wheel(e: WheelEvent) {
if (!$is_group_drag && !$is_display_drag) return;
if (!displays_scroll_box) return;
// apply custom scroll feature
e.preventDefault();
(displays_scroll_box as HTMLElement).scrollBy?.({
top: e.deltaY,
behavior: 'auto'
});
}
</script>
<svelte:window on:wheel={on_wheel} />
<div class="h-[calc(100dvh-3rem-(6*var(--spacing)))] flex flex-col gap-2">
<Splitpanes
horizontal
theme="mudics-stone-theme"
on:resized={handle_splitpane_resize}
dblClickSplitter={false}
>
{#if $pinned_display_id}
<!-- Pinned Item -->
<Pane maxSize={60} snapSize={20} bind:size={pinned_pane_size}>
<div class="h-full" transition:fade>
<div class="grid grid-rows-[2.5rem_auto] will-change-[height,opacity] h-full">
<div
class="bg-stone-700 rounded-t-2xl flex justify-between w-full p-1 min-w-0 basis-0 flex-1"
>
<span
class="text-xl font-bold pl-2 content-center truncate min-w-0"
title={$pinned_display?.name || '...'}
>
{$pinned_display?.name || '...'}
</span>
<div class="flex flex-row gap-1">
<div class="flex flex-row items-center mr-1">
<span class="text-stone-400"> Aktueller Status: </span>
<OnlineState
selected={false}
status={$pinned_display?.status ?? null}
className="flex items-center px-2"
/>
</div>
<Button
className="aspect-square p-1!"
bg="bg-stone-600"
click_function={(e) => {
e.stopPropagation();
}}
menu_options={get_display_menu_options($pinned_display_id)}
>
<Menu />
</Button>
<Button
title="Bildschirm nicht mehr anpinnen"
className="aspect-square p-1!"
bg="bg-stone-600"
click_function={close_pinned_display}
>
<PinOff />
</Button>
</div>
</div>
<div
class="h-full bg-stone-800 rounded-b-2xl overflow-hidden flex justify-center items-center"
>
{#if $pinned_display?.preview.url}
<img
src={$pinned_display.preview.url}
alt="preview"
class="max-h-full max-w-full object-cover bg-black"
/>
{:else}
<div class="size-full bg-black flex justify-center items-center">
<VideoOff class="size-[20%]" />
</div>
{/if}
</div>
</div>
</div>
</Pane>
{/if}
<Pane>
<div
class="min-h-0 h-full grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl overflow-hidden"
>
<!-- Normal Heading Left -->
<div class="bg-stone-700 flex justify-between w-full p-1 gap-2 min-w-0">
<span class="text-xl font-bold pl-2 content-center truncate min-w-0">
Verbundene Bildschirme
</span>
<div class="flex flex-row gap-1">
<button
class="min-w-40 px-4 rounded-xl cursor-pointer duration-200 transition-colors bg-stone-600"
onclick={async () => await toggle_all_selected_displays($display_groups)}
>
<span>{$all_groups_selected || false ? 'Alle abwählen' : 'Alle auswählen'}</span>
</button>
<div class="flex flex-row">
<Button
title="Bildschirme größer darstellen"
className="aspect-square p-1.5! pr-1! rounded-r-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, 1))}
click_function={() => {
change_height('display', 1);
}}
>
<ZoomIn class="size-full" />
</Button>
<Button
title="Bildschirme kleiner darstellen"
className="aspect-square p-1.5! pl-1! rounded-l-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, -1))}
click_function={() => {
change_height('display', -1);
}}
>
<ZoomOut class="size-full" />
</Button>
</div>
</div>
</div>
<div class="min-h-0 overflow-y-auto" bind:this={displays_scroll_box}>
<div
class="min-h-full p-2 flex flex-col gap-4"
use:dragHandleZone={{
items: $display_groups || [],
type: 'group',
flipDurationMs: dnd_flip_duration_ms,
dropFromOthersDisabled: true,
dropTargetStyle: { outline: 'none' }
}}
onconsider={async (e: CustomEvent) => {
$is_group_drag = true;
await set_new_display_group_order(e.detail.items);
}}
onfinalize={async (e: CustomEvent) => {
await set_new_display_group_order(e.detail.items);
$is_group_drag = false;
}}
>
{#if ($display_groups || []).length === 0}
<div class="text-stone-500 px-10 py-6 leading-relaxed text-center">
Es wurden noch keine Bildschirme hinzugefügt. Klicke oben rechts auf
<HighlightedText fg="text-stone-450" className="p-1!"
><Settings class="inline pb-1" /></HighlightedText
>
und
<HighlightedText fg="text-stone-450">Neuen Bildschirm hinzufügen</HighlightedText>.
</div>
{:else}
{#each $display_groups || [] as display_group (display_group.id)}
<!-- Each Group -->
<section
out:scale={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
animate:flip={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
class="outline-none"
>
<DisplayGroupObject
display_group_id={display_group.id}
{get_display_menu_options}
{close_pinned_display}
/>
</section>
{/each}
{/if}
</div>
</div>
</div>
</Pane>
</Splitpanes>
</div>
-399
View File
@@ -1,399 +0,0 @@
<script lang="ts">
import {
ClipboardPaste,
Download,
FolderPlus,
Pen,
RefreshCcw,
Scissors,
Trash2,
Upload,
ZoomIn,
ZoomOut
} from 'lucide-svelte';
import { change_height, current_height, next_height_step_size } from '$lib/ts/stores/ui_behavior';
import Button from '$lib/components/Button.svelte';
import PathBar from './PathBar.svelte';
import { selected_display_ids, selected_file_ids } from '$lib/ts/stores/select';
import {
current_file_path,
get_current_folder_elements,
get_file_by_id,
run_for_selected_files_on_selected_displays,
update_current_folder_on_selected_displays,
get_displays_where_path_exists,
create_folder_on_all_selected_displays
} from '$lib/ts/stores/files';
import { slide } from 'svelte/transition';
import InodeElement from '../lib/components/InodeElement.svelte';
import PopUp from '$lib/components/PopUp.svelte';
import { get_file_primary_key, type Inode, type PopupContent } from '$lib/ts/types';
import TextInput from '$lib/components/TextInput.svelte';
import {
first_letter_is_valid,
get_accepted_file_type_string,
is_valid_name
} from '$lib/ts/utils';
import { delete_files, rename_file } from '$lib/ts/api_handler';
import HighlightedText from '$lib/components/HighlightedText.svelte';
import { liveQuery, type Observable } from 'dexie';
import { download_file, add_upload } from '$lib/ts/file_transfer_handler';
let current_name: string = $state('');
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({
open: false,
snippet: null,
title: '',
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() {
popup_content.open = false;
}
async function create_new_folder() {
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) {
popup_close_function();
await run_for_selected_files_on_selected_displays(async (ip: string, file_names: string[]) => {
if (file_names.length !== 1) {
console.error(file_names);
return; // Error
}
await rename_file(ip, $current_file_path, file_names[0], new_file_name);
});
await update_current_folder_on_selected_displays();
}
const show_edit_file_popup = async () => {
const file = await get_file_by_id($selected_file_ids[0]);
if (!file) return;
const is_folder = file.type === 'inode/directory';
const extension = is_folder ? '' : '.' + file.name.split('.').at(-1) || '';
current_name = file.name.slice(0, file.name.length - extension.length);
current_valid = true;
popup_content = {
open: true,
snippet: edit_file_name_popup,
title: `${is_folder ? 'Ordner' : 'Datei'} umbenennen`,
title_icon: FolderPlus,
snippet_arg: extension,
closable: true
};
};
const show_new_folder_popup = async () => {
current_name = '';
current_valid = false;
display_names_where_path_does_not_exist = (
await get_displays_where_path_exists($current_file_path, $selected_display_ids, true)
).map((display) => display.name);
popup_content = {
open: true,
snippet: new_folder_popup,
title: 'Neuen Ordner erstellen',
title_icon: FolderPlus,
closable: true
};
};
const delete_folder_element_popup = () => {
popup_content = {
open: true,
snippet: delete_request_popup,
title: `${$selected_file_ids.length} ${$selected_file_ids.length === 1 ? 'Objekt' : 'Objekte'} wirklich löschen?`,
title_icon: Trash2,
closable: true
};
};
</script>
{#snippet new_folder_popup()}
{#if display_names_where_path_does_not_exist.length > 0}
<span class="leading-relaxed"
>Der aktuelle Pfad <HighlightedText
>{$current_file_path.slice(0, $current_file_path.length - 1)}</HighlightedText
> existiert nicht auf {display_names_where_path_does_not_exist.length === 1
? 'dem Bildschirm'
: 'den Bildschirmen'}
{#each display_names_where_path_does_not_exist as display_name, i}
{#if i !== 0}
,
{/if}
<HighlightedText>{display_name}</HighlightedText>
{/each}. Mit der Erstellung dieses Ordners wird der Pfad automatisch mit leeren Ordnern bis
zum aktuellen Pfad aufgefüllt.
</span>
{/if}
<TextInput
focused_on_start={true}
bind:current_value={current_name}
bind:current_valid
title="Ordnername"
is_valid_function={async (input: string) => {
const trimmed_input = input.trim();
if (trimmed_input.length === 0 || trimmed_input.length > 50)
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 (($current_folder_elements ?? []).some((e) => e.name === trimmed_input))
return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
enter_mode="submit"
enter_function={create_new_folder}
/>
<div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={create_new_folder} disabled={!current_valid}
>Neuen Ordner erstellen</Button
>
</div>
{/snippet}
{#snippet edit_file_name_popup(extension: string)}
<TextInput
focused_on_start={true}
bind:current_value={current_name}
bind:current_valid
title="Neuer {extension === '' ? 'Ordner' : 'Datei'}name"
is_valid_function={async (input: string) => {
const trimmed_input = input.trim() + extension;
if (trimmed_input.length === 0 || trimmed_input.length > 50)
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 (
($current_folder_elements ?? []).some(
(e) => e.name === trimmed_input && get_file_primary_key(e) !== $selected_file_ids[0]
)
)
return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
enter_mode="submit"
enter_function={async () => await edit_file_name(current_name.trim() + extension)}
{extension}
/>
<div class="flex flex-row justify-end gap-2">
<Button
className="px-4 font-bold"
click_function={async () => await edit_file_name(current_name.trim() + extension)}
disabled={!current_valid}>{extension === '' ? 'Ordner' : 'Datei'} umbenennen</Button
>
</div>
{/snippet}
{#snippet delete_request_popup()}
<div class="flex flex-col gap-1 h-full min-h-0 grow-0">
<span class="text-stone-400 px-1"
>{`${$selected_file_ids.length === 1 ? 'Folgendes Objekt' : `Folgende ${$selected_file_ids.length} Objekte`} löschen? (Wiederherstellung nicht möglich)`}</span
>
<div class="flex flex-col gap-2 overflow-auto h-full min-h-0 grow-0">
{#each $selected_files || [] as file}
<InodeElement {file} not_interactable />
{/each}
</div>
</div>
<div class="flex flex-row justify-end gap-2">
<Button className="button space font-bold" click_function={popup_close_function}>
Abbrechen
</Button>
<Button
className="button error space"
click_function={async () => {
popup_close_function();
await run_for_selected_files_on_selected_displays(
async (ip: string, file_names: string[]) => {
await delete_files(ip, $current_file_path, file_names);
}
);
await update_current_folder_on_selected_displays();
}}>Löschen</Button
>
</div>
{/snippet}
<input
class="hidden"
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-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-row">
<Button
title="Dateien größer darstellen"
className="aspect-square p-1.5! pr-1! rounded-r-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('file', $current_height, 1))}
click_function={() => {
change_height('file', 1);
}}
>
<ZoomIn class="size-full" />
</Button>
<Button
title="Dateien kleiner darstellen"
className="aspect-square p-1.5! pl-1! rounded-l-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('file', $current_height, -1))}
click_function={() => {
change_height('file', -1);
}}
>
<ZoomOut class="size-full" />
</Button>
</div>
</div>
<div class="flex flex-col gap-2 p-2 overflow-hidden relative rounded-b-2xl">
<div class="flex flex-col gap-2 p-2 bg-stone-750 rounded-xl">
<PathBar />
<div class="flex flex-row justify-between gap-6">
<div class="flex flex-row gap-2 shrink-0">
<Button
title="Neuen Ordner erstellen (Neuen Ordner mit ausgewählten Objekten erstellen)"
className="px-3 flex"
click_function={show_new_folder_popup}
disabled={$selected_display_ids.length === 0}><FolderPlus /></Button
>
<div class="border border-stone-700 my-1"></div>
<Button
title="Datei(en) hochladen"
className="px-3 flex"
click_function={() => {
if (file_input) file_input.click();
}}
disabled={$selected_display_ids.length === 0}><Upload /></Button
>
<Button
title="Ausgewählte Datei herunterladen"
className="px-3 flex"
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>
<Button
title="Aktuellen Ordner / Ausgewählte Datei(en) zwischen Bildschirmen synchronisieren"
className="px-3 flex gap-3"
disabled={$selected_display_ids.length === 0}
><RefreshCcw />
<span class="hidden 2xl:flex">Synchronisieren</span>
</Button>
</div>
<div class="flex flex-row gap-2">
<Button
title="Ausgewählte Datei(en) ausschneiden"
className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Scissors /></Button
>
<Button
title="Ausgewählte Datei(en) einfügen"
className="px-3 flex"
disabled={$selected_display_ids.length === 0}
>
<ClipboardPaste />
</Button>
<div class="border border-stone-700 my-1"></div>
<Button
title="Ausgewählte Datei umbenennen"
className="px-3 flex"
click_function={show_edit_file_popup}
disabled={$selected_file_ids.length !== 1}><Pen /></Button
>
<Button
title="Ausgewählte Datei(en) löschen"
hover_bg="bg-red-400"
active_bg="bg-red-500"
className="px-3 flex"
disabled={$selected_file_ids.length === 0}
click_function={delete_folder_element_popup}><Trash2 /></Button
>
</div>
</div>
</div>
<div class="min-h-0 h-full overflow-y-auto bg-stone-750 rounded-xl">
<div class="flex flex-col gap-2 p-2 min-h-0 max-w-full">
{#if $selected_display_ids.length === 0}
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center">
Es sind keine Bildschirme ausgewählt.
</span>
{:else}
{#each $current_folder_elements ?? [] as folder_element (get_file_primary_key(folder_element))}
<section in:slide={{ duration: 100 }} class="outline-none">
<InodeElement file={folder_element} />
</section>
{/each}
{#if ($current_folder_elements ?? []).length === 0}
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center max-w-full">
Es existieren keine Dateien auf {$selected_display_ids.length === 1
? 'dem ausgewähltem Bildchirm'
: 'den ausgewählten Bildschirmen'} im aktuellen Ordner. Klicke auf <HighlightedText
bg="bg-stone-700"
fg="text-stone-400"
className="p-1!"><Upload class="inline pb-1" /></HighlightedText
> um Datei(en) hochzuladen.
</span>
{/if}
{/if}
</div>
</div>
<PopUp
content={popup_content}
close_function={popup_close_function}
className="rounded-b-2xl"
snippet_container_class="overflow-hidden min-w-90"
/>
</div>
</div>
-106
View File
@@ -1,106 +0,0 @@
<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>
@@ -1,54 +0,0 @@
<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,255 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Editor } from '@tiptap/core';
import { StarterKit } from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import {
Baseline,
Bold,
Highlighter,
Italic,
PaintBucket,
QrCode,
Strikethrough
} from 'lucide-svelte';
import Button from '$lib/components/Button.svelte';
import { run_on_all_selected_displays } from '$lib/ts/stores/displays';
import { show_html } from '$lib/ts/api_handler';
import { get_selectable_color_classes } from '$lib/ts/stores/ui_behavior';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-text-style';
import Highlight from '@tiptap/extension-highlight';
type TextEditOption = {
onclick: () => void;
is_selected: () => boolean;
icon: typeof Bold;
title: string;
color?: ColorElement;
};
type ColorElement = {
color_string: () => string;
color_picker: () => void;
};
type ColorState = {
el: HTMLInputElement | null;
value: string;
};
let element = $state<HTMLElement>();
let editor_state = $state<{ editor: Editor | null }>({ editor: null });
let color_states: { text: ColorState; highlight: ColorState; bg: ColorState } = $state({
text: { el: null, value: '#b91c1c' },
highlight: { el: null, value: '#0c4a6e' },
bg: { el: null, value: '#1c1917' }
});
const text_edit_options: TextEditOption[][] = [
[
{
onclick: () => editor_state.editor?.chain().focus().toggleBold().run(),
is_selected: () => editor_state.editor?.isActive('bold') ?? false,
title: 'Fett (STRG+B)',
icon: Bold
},
{
onclick: () => editor_state.editor?.chain().focus().toggleItalic().run(),
is_selected: () => editor_state.editor?.isActive('italic') ?? false,
title: 'Kursiv (STRG+I)',
icon: Italic
},
{
onclick: () => editor_state.editor?.chain().focus().toggleStrike().run(),
is_selected: () => editor_state.editor?.isActive('strike') ?? false,
title: 'Durchgestrichen',
icon: Strikethrough
},
{
onclick: () => {},
is_selected: () => false,
title: 'QR-Code anfügen',
icon: QrCode
}
],
[
{
onclick: () => editor_state.editor?.chain().focus().setColor(color_states.text.value).run(),
is_selected: () =>
editor_state.editor?.isActive('textStyle', { color: color_states.text.value }) ?? false,
icon: Baseline,
title: 'Textfarbe',
color: get_color_element(color_states.text)
},
{
onclick: () =>
editor_state.editor
?.chain()
.focus()
.toggleHighlight({ color: color_states.highlight.value })
.run(),
is_selected: () =>
editor_state.editor?.isActive('highlight', { color: color_states.highlight.value }) ??
false,
icon: Highlighter,
title: 'Markierungsfarbe',
color: get_color_element(color_states.highlight)
},
{
onclick: () => color_states.bg.el?.click(),
is_selected: () => false,
title: 'Hintergrundfarbe',
icon: PaintBucket
}
]
];
function get_color_element(color_state: ColorState) {
return {
color_string: () => color_state.value,
color_picker: () => color_state.el?.click()
};
}
async function show_text() {
const html =
editor_state.editor?.getHTML() +
`<style>:root {--background-color: ${color_states.bg.value} !important;}
</style>`;
await run_on_all_selected_displays((d) => show_html(d.ip, html));
}
onMount(() => {
editor_state.editor = new Editor({
element: element,
extensions: [
StarterKit,
Placeholder.configure({
placeholder: 'Text hier eingeben ...'
}),
TextStyle,
Color,
Highlight.configure({
multicolor: true
})
],
content: '',
onTransaction: ({ editor }) => {
// Increment the state signal to force a re-render
editor_state = { editor };
},
autofocus: true
});
});
onDestroy(() => {
editor_state.editor?.destroy();
});
</script>
{#each Object.values(color_states) as color_state}
<input type="color" bind:this={color_state.el} bind:value={color_state.value} class="hidden" />
{/each}
<div data-testid="text-popup" class="flex flex-row gap-2 size-full">
<div
class="rounded-xl size-full shrink min-w-0 flex"
style="background-color: {color_states.bg.value};"
>
<div bind:this={element} class="size-full overflow-auto px-3 py-2"></div>
</div>
<div class="flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2">
{#each text_edit_options as edit_row}
<div class="flex flex-row gap-1">
{#each edit_row as option}
<div class="flex flex-row">
<button
title={option.title}
onclick={option.onclick}
class="p-1 {option.color
? 'rounded-l-xl'
: 'rounded-xl'} cursor-pointer duration-200 transition-colors {get_selectable_color_classes(
option.is_selected(),
{
bg: true,
hover: true,
active: true,
text: true
},
-100,
50
)}"
>
{#if option.icon}
{@const Icon = option.icon}
<Icon class="h-7 w-7" />
{/if}
</button>
{#if option.color}
<button
aria-label="Color"
onclick={option.color.color_picker}
title={option.title}
class="flex p-1 rounded-r-xl cursor-pointer duration-200 transition-colors justify-center items-center {get_selectable_color_classes(
option.is_selected(),
{
bg: true,
hover: true,
active: true,
text: true
},
-100,
50
)}"
>
<span
class="h-7 w-3 rounded-full"
style="background-color: {option.color.color_string()};"
></span>
</button>
{/if}
</div>
{/each}
</div>
{/each}
</div>
<Button click_function={show_text} className="w-full font-bold">Text anzeigen</Button>
</div>
</div>
<style>
:global(.tiptap) {
width: 100%;
height: 100%;
--tw-outline-style: none;
outline-style: none;
}
:global(.tiptap ul),
:global(.tiptap ol) {
padding-left: 1.5rem;
}
:global(.tiptap ul) {
list-style: disc;
}
:global(.tiptap ol) {
list-style: decimal;
}
:global(.tiptap p) {
margin-bottom: 0.5rem;
}
:global(.tiptap p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
pointer-events: none;
color: oklch(44.4% 0.011 73.639);
float: left;
height: 0;
opacity: 0.7;
}
@@ -0,0 +1,47 @@
<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>
-1
View File
@@ -201,7 +201,6 @@ module.exports = {
'group-active:border-stone-900',
'group-active:border-stone-950',
'h-10',
'h-15',
'h-20',
'h-25',
+117
View File
@@ -0,0 +1,117 @@
import { notifications } from "./stores/notification";
import { to_display_status, type DisplayStatus, type FolderElement } from "./types";
import { get_uuid } from "./utils";
export async function get_screenshot(ip: string) {
const options = { method: 'PATCH' };
return await request_display(ip, '/takeScreenshot', options);
}
export async function open_file(ip: string, path_to_file: string): Promise<boolean> {
const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } };
const raw_response = await request_display(ip, `/file${path_to_file}`, options);
return !!raw_response;
}
export async function get_file_data(ip: string, path: string): Promise<FolderElement[]> {
interface FileInfo {
name: string;
type: string;
size: string;
created: string;
}
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do
typ=$(file -b --mime-type -- "$f")
size=$(stat -c '%s' -- "$f")
created=$(stat -c '%w' -- "$f")
[ "$created" = "-" ] && created=$(stat -c '%y' -- "$f")
jq -n --arg name "$f" --arg type "$typ" --arg size "$size" --arg created "$created" \
'{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n'
echo
done
` })
};
const raw_response = await request_display(ip, '/shellCommand', options);
if (!raw_response) return [];
const response: FileInfo[] = raw_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 folder_element: FolderElement = {
id: get_uuid(),
hash: JSON.stringify(response),
thumbnail_url: null,
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 ping_ip(ip: string): Promise<DisplayStatus> {
const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' });
if (!raw_response) return null;
return raw_response.status ? to_display_status(raw_response.status) : null;
}
async function request_display(ip: string, api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<null | any> {
const url = `http://${ip}:1323/api${api_route}`;
return await raw_request(url, options);
}
async function request_control(api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<null | any> {
const url = `${window.location.origin}/api${api_route}`;
return await raw_request(url, options);
}
async function raw_request(url: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<null | any> {
try {
const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`;
console.log(url + cache_buster)
const response = await fetch(url + cache_buster, options);
if (!response.ok) {
console.error(`HTTP error! Status: ${response.status}`);
notifications.push("error", `HTTP-Fehler bei API-Anfrage`, `${url}\nHTTP-Status: ${response.status}`);
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
return await response.blob();
} else {
const json = await response.json();
if (json.error && json.error !== '') {
notifications.push("error", `Interner Fehler bei API-Anfrage`, `${url}\nJSON: ${JSON.stringify(json)}`);
}
return json;
}
} catch (error: any) {
if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) {
console.log("Request failed - Is the targeted device online?")
} else {
console.log(typeof error, error instanceof TypeError, error.message);
console.error(error);
notifications.push("error", `Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
}
return null;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { get } from "svelte/store";
import { displays, update_displays_with_map } from "./stores/displays"
import { ping_ip } from "./api_handler";
import type { Display } from "./types";
import { change_file_path, update_all_display_files } from "./stores/files";
const update_display_status_interval_seconds = 20;
export function on_start() {
update_all_display_status();
setInterval(update_all_display_status, update_display_status_interval_seconds * 1000);
update_all_display_files('/');
}
async function update_all_display_status() {
update_displays_with_map(async (display: Display) => {
const new_status = await ping_ip(display.ip);
if (new_status === null && display.status !== null) return display;
return { ...display, status: new_status, };
});
console.log("Display Status updated")
}
+171
View File
@@ -0,0 +1,171 @@
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 DisplayGroupObject from "../../components/DisplayGroupObject.svelte";
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 function edit_display_data(display_id: string, ip: string, mac: string | null, name: string) {
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) {
console.log(display_id);
displays.update((displays: DisplayGroup[]) => {
displays = displays.map(display_group => ({
...display_group,
data: display_group.data.filter(display => display.id !== display_id)
}));
return displays;
});
// TODO remove ID from Files usw.
}
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);
}
}
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);
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);
console.log(old_hash, new_hash);
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);
}
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");
}
+203
View File
@@ -0,0 +1,203 @@
import { get, writable, type Writable } from "svelte/store";
import type { Display, FolderElement } from "../types";
import { displays, update_displays_with_map } from "./displays";
import { selected_display_ids, selected_file_ids } from "./select";
import { get_file_data } from "../api_handler";
import { get_uuid } from "../utils";
import type { Folder } from "lucide-svelte";
import { notifications } from "./notification";
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 function change_file_path(new_path: string) {
current_file_path.update(() => {
return new_path;
});
selected_file_ids.update(() => {
return [];
})
setTimeout(async () => { await update_all_display_files(new_path) }, 0);
}
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 [];
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 async function update_all_display_files(path: string) {
for (const display_group of get(displays)) {
for (const display of display_group.data) {
await update_folder_elements_recursively(display, path);
}
}
}
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);
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);
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 updates_files_on_display(display_id: string, new_folder_elements: FolderElement[], file_path: string) {
// all_files.update((files) => {
// if (!files.hasOwnProperty(file_path)) {
// files[file_path] = {};
// }
// files[file_path][display_id] = new_folder_elements;
// return files;
// });
// }
// async function get_files_on_all_displays(current_file_path: string) {
// for (const display_group of get(displays)) {
// for (const display of display_group.data) {
// const new_folder_elements = await get_file_data(display.ip, current_file_path);
// updates_files_on_display(display.id, new_folder_elements, current_file_path)
// console.log(new_folder_elements)
// }
// }
// }
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) {
if (file_on_display.name === existing_file.name) {
if (file_on_display.hash === existing_file.hash) {
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
return b.date_created.getTime() - a.date_created.getTime();
});
return files;
}
@@ -1,4 +1,4 @@
import { writable } from "svelte/store";
import { get, writable } from "svelte/store";
export type Notification = {
id: number;
@@ -14,7 +14,7 @@ function createNotifications() {
function push(type: "error" | "success" | "info", title: string, message: string = "", className: string = "") {
const id = Date.now();
const duration = type === "error" ? 16000 : 4000;
const duration = type === "error" ? 8000 : 4000;
update((n) => [...n, { id, title, message, duration, className, type }]);
setTimeout(() => {
update((n) => n.filter((x) => x.id !== id));
+23
View File
@@ -0,0 +1,23 @@
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);
}
+112
View File
@@ -0,0 +1,112 @@
import { FileBox, FileImage, FileVideoCamera, ImagePlay, type X } from "lucide-svelte";
import type { Snippet } from "svelte";
export type SupportedFileType = {
display_name:
string; mime_type:
string; icon: typeof X;
};
export const supported_file_types: Record<string, SupportedFileType> = {
'.mp4': {
display_name: 'MP4',
mime_type: 'video/mp4',
icon: FileVideoCamera,
},
'.jpg': {
display_name: 'JPG',
mime_type: 'image/jpg',
icon: FileImage,
},
'.jpeg': {
display_name: 'JPG',
mime_type: 'image/jpeg',
icon: FileImage,
},
'.png': {
display_name: 'PNG',
mime_type: 'image/png',
icon: FileImage,
},
'.gif': {
display_name: 'GIF',
mime_type: 'image/gif',
icon: ImagePlay,
},
'.pptx': {
display_name: 'PPTX',
mime_type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
icon: FileBox,
},
'.odp': {
display_name: 'ODP',
mime_type: 'application/vnd.oasis.opendocument.presentation',
icon: FileBox,
}
}
export type FolderElement = {
id?: string;
hash: string | null;
thumbnail_url: string | null;
name: string;
type: string;
date_created: Date;
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;
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;
}
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 '???';
}
}
+42
View File
@@ -0,0 +1,42 @@
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 "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
}
+1 -9
View File
@@ -2,24 +2,16 @@ module plg-mudics/control
go 1.24.5
require (
github.com/labstack/echo/v4 v4.13.4
github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a
)
require github.com/labstack/echo/v4 v4.13.4
require (
github.com/josharian/native v1.0.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/ethernet v0.0.0-20190313224307-5b5fc417d966 // indirect
github.com/mdlayher/packet v1.0.0 // indirect
github.com/mdlayher/socket v0.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.11.0 // indirect
-27
View File
@@ -1,11 +1,5 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -14,15 +8,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/ethernet v0.0.0-20190313224307-5b5fc417d966 h1:O3p5UmisBhl3V6lgs4Vdfg8HpjzbWJPyOfGLdwVJSmI=
github.com/mdlayher/ethernet v0.0.0-20190313224307-5b5fc417d966/go.mod h1:5s5p/sMJ6sNsFl6uCh85lkFGV8kLuIYJCRJLavVJwvg=
github.com/mdlayher/packet v1.0.0 h1:InhZJbdShQYt6XV2GPj5XHxChzOfhJJOMbvnGAmOfQ8=
github.com/mdlayher/packet v1.0.0/go.mod h1:eE7/ctqDhoiRhQ44ko5JZU2zxB88g+JH/6jmnjzPjOU=
github.com/mdlayher/raw v0.0.0-20190313224157-43dbcdd7739d/go.mod h1:r1fbeITl2xL/zLbVnNHFyOzQJTgr/3fpf1lJX/cjzR8=
github.com/mdlayher/socket v0.2.1 h1:F2aaOwb53VsBE+ebRS9bLd7yPOfYUMC8lOODdCBDY6w=
github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E=
github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a h1:fpQi42PM6HzbHwNg6a7OvLbh5l49Rgr7GCARozjHwio=
github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a/go.mod h1:XX6nvyQpGc8E4KaQm62FyexOYd36ilpyfYf8wjgMDV8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@@ -31,28 +16,16 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+2 -59
View File
@@ -4,37 +4,16 @@ import (
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"plg-mudics/control/frontend"
"plg-mudics/shared"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/mdlayher/wol"
)
var storageFile string
func main() {
var err error
path, err := getStoragePath()
if err != nil {
slog.Error("Failed to initialize storage path", "error", err)
os.Exit(1)
}
storageFile = filepath.Join(path, "storage.json")
// Ensure storage.json exists
if _, err := os.Stat(storageFile); os.IsNotExist(err) {
if err := os.WriteFile(storageFile, []byte("{}"), 0644); err != nil {
slog.Error("Failed to initialize storage.json", "error", err)
os.Exit(1)
}
}
e := echo.New()
// Serve the embedded SvelteKit frontend
@@ -46,25 +25,14 @@ func main() {
// Servers all API endpoints, e.g. our custom logic
apiGroup := e.Group("/api")
apiGroup.GET("/ping", pingRoute)
apiGroup.POST("/wakeOnLan", wakeOnLanRoute)
apiGroup.GET("/storage", getStorageRoute)
apiGroup.POST("/storage", setStorageRoute)
port := "8080"
// the order is important, the open browser command exitsts as soon as the winodw is closed
// and since its the last action in the main go func all other goroutines (e.g. the webserver) are killed
go func() {
go shared.OpenBrowserWindow("http://localhost:"+port, false, false)
err := e.Start(":" + port)
if err != nil {
slog.Error("Failed to start Echo Webserver", "error", err)
os.Exit(1)
}
}()
err = shared.OpenBrowserWindow("http://localhost:"+port, false, false)
if err != nil {
slog.Error("Failed to open browser window", "error", err)
os.Exit(1)
}
}
@@ -93,28 +61,3 @@ type PingResponse struct {
Status string `json:"status"`
Error string `json:"error"`
}
type WakeOnLanRequest struct {
MACAddress string `json:"mac_address"`
}
func wakeOnLanRoute(ctx echo.Context) error {
var data WakeOnLanRequest
if err := ctx.Bind(&data); err != nil {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
}
mac, err := net.ParseMAC(data.MACAddress)
if err != nil {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid MAC address"})
}
client, err := wol.NewClient()
if err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to create Wake-on-LAN client"})
}
if err := client.Wake("255.255.255.255", mac); err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to send Wake-on-LAN packet"})
}
return ctx.JSON(http.StatusOK, struct{}{})
}
-16
View File
@@ -1,16 +0,0 @@
meta {
name: Get Store
type: http
seq: 2
}
get {
url: http://localhost:8080/api/storage
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}
-22
View File
@@ -1,22 +0,0 @@
meta {
name: Set Store
type: http
seq: 3
}
post {
url: http://localhost:8080/api/storage
body: json
auth: inherit
}
body:json {
{
"key": "value"
}
}
settings {
encodeUrl: true
timeout: 0
}
-22
View File
@@ -1,22 +0,0 @@
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
}
-61
View File
@@ -1,61 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"plg-mudics/shared"
"github.com/labstack/echo/v4"
)
func getStorageRoute(ctx echo.Context) error {
data, err := os.ReadFile(storageFile)
if err != nil {
slog.Error("Could not read storage file", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Could not read storage file"})
}
var content interface{}
if err := json.Unmarshal(data, &content); err != nil {
slog.Error("Could not parse storage file", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Could not parse storage file"})
}
return ctx.JSON(http.StatusOK, content)
}
func setStorageRoute(ctx echo.Context) error {
var payload interface{}
if err := ctx.Bind(&payload); err != nil {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid JSON payload"})
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to marshal storage file"})
}
if err := os.WriteFile(storageFile, data, 0644); err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to write storage file"})
}
return ctx.JSON(http.StatusOK, map[string]interface{}{})
}
func getStoragePath() (string, error) {
var storagePath string
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("unable to determine user home directory: %w", err)
}
storagePath = filepath.Join(home, ".local", "share", "plg-mudics", "control")
if err := os.MkdirAll(storagePath, os.ModePerm); err != nil {
return "", fmt.Errorf("failed to create local config directory: %w", err)
}
return storagePath, nil
}
+12 -99
View File
@@ -1,103 +1,16 @@
# API
# PLG-MuDiCS Display
All response and request bodies are `application/json` if not otherwise specified. If no response schema is specified an empty json object `{}` is returned.
## Runtime Requirements
## Default
- Bash
- Chromium
- LibreOffice (presentation view)
- ImageMagick (file preview)
- ffmpeg (file preview)
- Ghostscript (file preview)
### Responses
## Development Setup
#### All Errors
If not specified otherwise.
- `description`: string
#### 400
Request uses invalid JSON syntax or does not follow request schema.
#### 500
Something (undefined) on the display side has gone very wrong.
## GET `/ping`
### Responses
#### 200
- `version`: str
## PATCH `/shellCommand`
### Responses
#### 200
Even when the command itself fails.
- `stdout`: string
- `stderr`: string
- `exitCode`: int
## PATCH `/keyboardInput`
### Request Body
- `inputs`: list
- `key`: string for key
- `action`: "press" or "release"
## PATCH `/showHTML`
### Request Body
- `html`: string
## PATCH `/takeScreenshot`
### Responses
#### 200
The screenshot as binary in the response body.
## POST `/file/<path>` - Upload File
### Responses
#### 409 - Conflict
File with the same path and name already exists.
## GET `/file/<path>`
### Responses
#### 404
Requested file was not found at the path.
## PATCH `/file/<path>` - Open File
### Responses
#### 404
Requested file was not found at the path.
#### 415 - Unsupported Media Type
The type of the file is not available for display.
## GET `/file/preview/<path>`
### Responses
#### 404
Requested file was not found at the path.
#### 415 - Unsupported Media Type
The type of the file is not available preview generation.
- install Go (at least version v1.25)
- build template: `go generate ./..`
- run code: `go run .`
-2
View File
@@ -6,8 +6,6 @@ require (
github.com/a-h/templ v0.3.960
github.com/labstack/echo/v4 v4.13.4
github.com/micmonay/keybd_event v1.1.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/gabriel-vasile/mimetype v1.4.11
)
require (
-4
View File
@@ -14,8 +14,6 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
@@ -32,8 +30,6 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+1 -3
View File
@@ -24,10 +24,8 @@ func main() {
return
}
port := "1323"
// the order is important, the open browser command exitsts as soon as the winodw is closed
// and since its the last action in the main go func all other goroutines (e.g. the webserver) are killed
go web.StartWebServer(VERSION, port)
err = shared.OpenBrowserWindow("http://localhost:"+port, true, true)
if err != nil {
slog.Error("Failed to open browser window", "error", err)
-63
View File
@@ -1,63 +0,0 @@
package pkg
import (
"fmt"
"os"
"os/exec"
"plg-mudics/shared"
"syscall"
"github.com/gabriel-vasile/mimetype"
)
var FileHandler fileHandler = fileHandler{}
type fileHandler struct {
runningProgram *exec.Cmd
}
func (fh *fileHandler) OpenFile(path string) error {
var err error
mType, err := mimetype.DetectFile(path)
if err != nil {
return fmt.Errorf("failed to detect mime type: %w", err)
}
tempDirPath, err := os.MkdirTemp("", "plg-mudics-program-profile-")
if err != nil {
return fmt.Errorf("failed to create temporary profile directory: %w", err)
}
err = fh.CloseRunningProgram()
if err != nil {
return err
}
switch mType.String() {
case "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation":
fh.runningProgram = exec.Command("soffice", "--show", path, "--nologo", "--view", "--norestore", fmt.Sprintf("-env:UserInstallation=file://%s", tempDirPath))
case "application/pdf":
fh.runningProgram = exec.Command("xreader", path, "--presentation")
default:
return fmt.Errorf("unsupported file type: %s", mType.String())
}
fh.runningProgram.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
result := shared.RunShellCommandNonBlocking(fh.runningProgram)
if result.ExitCode != 0 {
return fmt.Errorf("could not open pdf: %s (%d)", result.Stderr, result.ExitCode)
}
return nil
}
func (fh *fileHandler) CloseRunningProgram() error {
if fh.runningProgram == nil {
return nil
}
err := syscall.Kill(-fh.runningProgram.Process.Pid, syscall.SIGTERM)
fh.runningProgram = nil
return err
}
+4 -5
View File
@@ -10,9 +10,8 @@ import (
"strings"
"time"
)
var ErrFileTypePreviewNotSupported = errors.New("file type not supported for preview")
var ErrFilePreviewToolsMissing = errors.New("required tools for file preview are missing")
var ErrFileTypePreviewNotSupported = errors.New("file type not supported for preview");
var ErrFilePreviewToolsMissing = errors.New("required tools for file preview are missing");
func GenerateFilePreview(inputPath string) (string, error) {
var err error
@@ -37,7 +36,7 @@ func GenerateFilePreview(inputPath string) (string, error) {
}
func generateImagePreview(inputPath string, outputPath string) error {
cmd := exec.Command("magick", inputPath, "-gravity", "center", "-crop", "1:1", "-thumbnail", "100", "-quality", "50", outputPath)
cmd := exec.Command("magick", inputPath, "-thumbnail", "100x100", "-quality", "50", outputPath)
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
if result.ExitCode == 127 {
@@ -54,7 +53,7 @@ func generatePDFPreview(inputPath string, outputPath string) error {
return ErrFilePreviewToolsMissing
}
cmd := exec.Command("magick", fmt.Sprintf("%s[0]", inputPath), "-gravity", "center", "-crop", "1:1", "-thumbnail", "100x100", "-quality", "50", outputPath)
cmd := exec.Command("magick", fmt.Sprintf("%s[0]", inputPath), "-thumbnail", "100x100", "-quality", "50", outputPath)
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
if result.ExitCode == 127 {
+16 -29
View File
@@ -16,6 +16,8 @@ import (
"github.com/micmonay/keybd_event"
)
func GetDeviceIp() (string, error) {
addrs, err := net.InterfaceAddrs()
if err != nil {
@@ -47,41 +49,24 @@ func GetDeviceMac() (string, error) {
return "", fmt.Errorf("no suitable MAC address found")
}
type KeyAction int
const (
KeyPress KeyAction = iota
KeyRelease
)
type Input struct {
Key int
Action KeyAction
func OpenPresentation(path string) error {
cmd := exec.Command("bash", "-c", "-r", fmt.Sprintf("soffice --show %s -nologo -norestore", path))
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
return errors.New(result.Stderr)
}
return nil
}
func KeyboardInput(inputs []Input) error {
var err error
func KeyboardInput(key int) error {
kb, err := keybd_event.NewKeyBonding()
if err != nil {
return fmt.Errorf("failed to create key bonding: %w", err)
}
kb.SetKeys(key)
for _, input := range inputs {
kb.SetKeys(input.Key)
switch input.Action {
case KeyPress:
err = kb.Press()
case KeyRelease:
err = kb.Release()
}
if err != nil {
return fmt.Errorf("failed to run key event: %w", err)
}
time.Sleep(time.Microsecond * 100)
if err := kb.Launching(); err != nil {
return fmt.Errorf("failed to launch key event: %w", err)
}
return nil
@@ -105,6 +90,7 @@ func TakeScreenshot() (string, error) {
return "", errors.New("no screenshot utility found or all failed")
}
func GetStoragePath() (string, error) {
var storagePath string
@@ -112,7 +98,7 @@ func GetStoragePath() (string, error) {
if err != nil {
return "", fmt.Errorf("unable to determine user home directory: %w", err)
}
storagePath = filepath.Join(home, ".local", "share", "plg-mudics", "display")
storagePath = filepath.Join(home, ".local", "share", "plg-connect-display")
if err := os.MkdirAll(storagePath, os.ModePerm); err != nil {
return "", fmt.Errorf("failed to create local config directory: %w", err)
}
@@ -150,3 +136,4 @@ func ResolveStorageFilePath(pathParam string) (string, bool, error) {
return fullPath, true, nil
}
+1 -7
View File
@@ -12,16 +12,10 @@ patch {
body:json {
{
"inputs": [
{
"key": "VK_T",
"action": "press"
}
]
"key": "VK_T"
}
}
settings {
encodeUrl: true
timeout: 0
}
-12
View File
@@ -1,12 +0,0 @@
package web
import (
"embed"
"io/fs"
)
//go:embed all:static
var staticDir embed.FS
// BuildDirFS contains the embedded dist directory files (without the "build" prefix)
var StaticDirFS, _ = fs.Sub(staticDir, "static")
+76 -168
View File
@@ -5,27 +5,37 @@ import (
"context"
"errors"
"fmt"
"image/color"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
shared "plg-mudics/shared"
"strings"
"github.com/gabriel-vasile/mimetype"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/skip2/go-qrcode"
"github.com/micmonay/keybd_event"
"plg-mudics/display/pkg"
)
type ErrorResponse struct {
Error string `json:"error"`
}
var version string
var sseConnection chan string
var supportedExtensions = map[string]bool{
".mp4": true,
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".pptx": true,
".odp": true,
}
func StartWebServer(v string, port string) {
version = v
@@ -37,13 +47,6 @@ func StartWebServer(v string, port string) {
e.GET("/splash", func(ctx echo.Context) error {
return ctx.HTML(http.StatusOK, shared.SplashScreenTemplate)
})
e.GET("/qr", qrRoute)
staticGroup := e.Group("/static")
staticGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Filesystem: http.FS(StaticDirFS),
HTML5: true,
}))
apiGroup := e.Group("/api")
apiGroup.Use(middleware.CORS())
@@ -91,9 +94,8 @@ func sseRoute(ctx echo.Context) error {
if err != nil {
slog.Error("Failed to get device MAC address", "error", err)
}
showQR := !isPortFree(8080)
var status bytes.Buffer
deviceInfoTemplate(ip, mac, showQR).Render(context.Background(), &status)
deviceInfoTemplate(ip, mac).Render(context.Background(), &status)
connectedEvent := Event{
Data: status.Bytes(),
}
@@ -122,45 +124,14 @@ func sseRoute(ctx echo.Context) error {
}
}
func qrRoute(c echo.Context) error {
data := c.QueryParam("data")
if data == "" {
return c.String(http.StatusBadRequest, "missing data")
}
qr, err := qrcode.New(data, qrcode.Medium)
if err != nil {
return c.String(http.StatusInternalServerError, "could not generate qr")
}
qr.DisableBorder = true
qr.ForegroundColor = color.RGBA{R: 0x1c, G: 0x19, B: 0x17, A: 0xff}
qr.BackgroundColor = color.RGBA{R: 0xe7, G: 0xe5, B: 0xe4, A: 0xff}
png, err := qr.PNG(-1)
if err != nil {
return c.String(http.StatusInternalServerError, "could not encode png")
}
return c.Blob(http.StatusOK, "image/png", png)
}
func extractFilePathMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
raw := ctx.Param("path")
decoded, err := url.PathUnescape(raw)
pathParam := ctx.Param("path")
fullPath, exists, err := pkg.ResolveStorageFilePath(pathParam)
if err != nil {
slog.Warn("Invalid path encoding", "path", raw, "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid file path"})
slog.Warn("Failed to validate file path", "path", pathParam, "error", err)
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "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("fileExists", exists)
return next(ctx)
@@ -173,14 +144,14 @@ func shellCommandRoute(ctx echo.Context) error {
}
if err := ctx.Bind(&commandInput); err != nil {
slog.Error("Failed to parse shell command", "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
}
cmd := exec.Command("bash", "-c", commandInput.Command)
storagePath, err := pkg.GetStoragePath()
if err != nil {
slog.Error("Failed to get storage path", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to get storage path"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
}
cmd.Dir = storagePath
@@ -195,169 +166,117 @@ func shellCommandRoute(ctx echo.Context) error {
func keyboardInputRoute(ctx echo.Context) error {
var request struct {
Inputs []struct {
Key string `json:"key"`
Action string `json:"action"`
} `json:"inputs"`
}
if err := ctx.Bind(&request); err != nil {
slog.Error("Failed to parse keyboard input", "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
}
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[input.Key]
code, ok := pkg.KeyboardEvents[request.Key]
if !ok {
slog.Error("Unsupported key", "key", input.Key)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: fmt.Sprintf("Unsupported key: %s", input.Key)})
slog.Error("Unsupported key", "key", request.Key)
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported key: %s", request.Key)})
}
var action pkg.KeyAction
if input.Action == "press" {
action = pkg.KeyPress
}
if input.Action == "release" {
action = pkg.KeyRelease
}
inputs = append(inputs, pkg.Input{
Key: code,
Action: action,
})
}
err := pkg.KeyboardInput(inputs)
err := pkg.KeyboardInput(code)
if err != nil {
slog.Error("Failed to send keyboard input", "inputs", inputs, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to send keyboard input"})
slog.Error("Failed to send keyboard input", "key", request.Key, "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to send keyboard input"})
}
slog.Info("Keyboard input sent")
return ctx.JSON(http.StatusOK, struct{}{})
slog.Info("Keyboard input sent", "key", request.Key)
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
}
func uploadFileRoute(ctx echo.Context) error {
var err error
fullPath := ctx.Get("fullPath").(string)
// Ensure parent directories exist
if err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm); err != nil {
slog.Error("Failed to create storage path", "error", err, "path", fullPath)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to prepare storage directory"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to prepare storage directory"})
}
if ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusConflict, shared.ErrorResponse{Description: "File already exists"})
return ctx.JSON(http.StatusConflict, ErrorResponse{Error: "File already exists"})
}
file, err := os.Create(fullPath)
if err != nil {
slog.Error("Failed to create file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to create file"})
}
defer func() {
fileCloseErr := file.Close()
if fileCloseErr != nil {
slog.Error("Failed to close file", "file", fullPath, "error", fileCloseErr)
}
if err != nil {
os.Remove(fullPath)
}
}()
_, err = io.Copy(file, ctx.Request().Body)
if err != nil {
slog.Error("Failed to write file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to write file"})
ext := strings.ToLower(filepath.Ext(fullPath))
if !supportedExtensions[ext] {
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported file extension: %s", ext)})
}
err = file.Sync() // ensure data is flushed to disk
data, err := io.ReadAll(ctx.Request().Body)
if err != nil {
slog.Error("Failed to sync file to disk", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to save file"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to read file body"})
}
if err := os.WriteFile(fullPath, data, os.ModePerm); err != nil {
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to save file"})
}
slog.Info("File uploaded successfully", "path", fullPath)
return ctx.JSON(http.StatusOK, struct{}{})
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "File uploaded successfully"})
}
func downloadFileRoute(ctx echo.Context) error {
fullPath := ctx.Get("fullPath").(string)
if !ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
}
slog.Info("Serving file for download", "path", fullPath)
file, err := os.Open(fullPath)
err := ctx.File(fullPath)
if err != nil {
slog.Error("Failed to open file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to open file"})
slog.Error("Failed to serve file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
}
defer file.Close()
return ctx.Stream(http.StatusOK, "application/octet-stream", file)
slog.Info("File downloaded successfully", "path", fullPath)
return nil
}
func openFileRoute(ctx echo.Context) error {
var err error
pathParam := ctx.Param("path")
fullPath := ctx.Get("fullPath").(string)
if !ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
}
if sseConnection == nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Cant connect to display browser client"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Cant connect to display browser client"})
}
err = resetView()
err := resetView()
if err != nil {
slog.Error("Failed to reset view", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to reset view"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to reset view"})
}
mType, err := mimetype.DetectFile(fullPath)
if err != nil {
slog.Error("Failed to detect mime type", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to detect file type"})
}
switch mType.String() {
case "video/mp4":
ext := strings.ToLower(filepath.Ext(fullPath))
switch ext {
case ".mp4":
var templateBuffer bytes.Buffer
videoTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String()
case "image/jpeg", "image/png", "image/gif":
case ".jpg", ".jpeg", ".png", ".gif":
var templateBuffer bytes.Buffer
imageTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String()
case "application/pdf", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation":
err = pkg.FileHandler.OpenFile(fullPath)
default:
slog.Info("Unsupported file type", "type", mType)
return ctx.JSON(http.StatusUnsupportedMediaType, shared.ErrorResponse{Description: "Unsupported file type: " + mType.String()})
}
case ".pptx", ".odp":
err := pkg.OpenPresentation(fullPath)
if err != nil {
slog.Error("Failed to open file", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to open file"})
slog.Error("Failed to open presentation", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to open presentation"})
}
default:
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Unsupported file type"})
}
slog.Info("Successfully run file", "file", pathParam)
return ctx.JSON(http.StatusOK, struct{}{})
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
}
func showHTMLRoute(ctx echo.Context) error {
@@ -366,19 +285,19 @@ func showHTMLRoute(ctx echo.Context) error {
}
if err := ctx.Bind(&request); err != nil {
slog.Error("Failed to parse request", "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
}
err := resetView()
if err != nil {
slog.Error("Failed to reset view", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to reset view"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to reset view"})
}
sseConnection <- request.HTML
slog.Info("HTML content sent to client")
return ctx.JSON(http.StatusOK, struct{}{})
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
}
func pingRoute(ctx echo.Context) error {
@@ -393,13 +312,13 @@ func takeScreenshotRoute(ctx echo.Context) error {
screenshotPath, err := pkg.TakeScreenshot()
if err != nil {
slog.Error("Failed to take screenshot", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to take screenshot"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
}
err = ctx.File(screenshotPath)
if err != nil {
slog.Error("Failed to serve file", "file", screenshotPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to serve file"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
}
return nil
@@ -409,19 +328,19 @@ func previewRoute(ctx echo.Context) error {
fullPath := ctx.Get("fullPath").(string)
exists := ctx.Get("fileExists").(bool)
if !exists {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
}
outputFilePath, err := pkg.GenerateFilePreview(fullPath)
if err != nil {
slog.Error("Failed to generate preview", "file", fullPath, "error", err)
if errors.Is(err, pkg.ErrFileTypePreviewNotSupported) {
return ctx.JSON(http.StatusUnsupportedMediaType, shared.ErrorResponse{Description: "File type not supported for preview"})
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "File type not supported for preview"})
}
if errors.Is(err, pkg.ErrFilePreviewToolsMissing) {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Required tools for file preview are missing"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Required tools for file preview are missing"})
}
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to generate preview"})
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to generate preview"})
}
return ctx.File(outputFilePath)
@@ -429,22 +348,11 @@ func previewRoute(ctx echo.Context) error {
// Reset previous file views so they dont collide with the new one
func resetView() error {
err := pkg.FileHandler.CloseRunningProgram()
err := pkg.KeyboardInput(keybd_event.VK_ESC)
if err != nil {
return fmt.Errorf("failed to close running program: %w", err)
return fmt.Errorf("failed to send ESC key: %w", err)
}
sseConnection <- ""
return nil
}
func isPortFree(port int) bool {
addr := fmt.Sprintf("127.0.0.1:%d", port)
l, err := net.Listen("tcp", addr)
if err != nil {
return false
}
_ = l.Close()
return true
}
+20 -59
View File
@@ -3,61 +3,40 @@ package web
templ indexTemplate() {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>PLG Connect Display</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/htmx-ext-sse.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js" integrity="sha384-Akqfrbj/HpNVo8k11SXBb6TlBWmXXlYQrCSqEWmyKJe+hDm3Z/B2WVG4smwBkRVm" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2" crossorigin="anonymous"></script>
<style>
:root {
--background-color: black;
--foreground-color: oklch(92.3% 0.003 48.717);
--font-size: 5rem;
}
body {
display: flex;
justify-content: center;
/* centers horizontally */
align-items: center;
/* centers vertically */
width: 100vw;
/* Viewport width */
height: 100vh;
/* Viewport height */
justify-content: center; /* centers horizontally */
align-items: center; /* centers vertically */
width: 100vw; /* Viewport width */
height: 100vh; /* Viewport height */
margin: 0;
padding: 0;
overflow: hidden;
background-color: var(--background-color);
color: var(--foreground-color);
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
background-color: hsl(256, 10%, 10%);
color: hsl(256, 90%, 95%);
}
video,
img,
iframe {
width: 100vw;
/* Viewport width */
height: 100vh;
/* Viewport height */
video, img, iframe {
width: 100vw; /* Viewport width */
height: 100vh; /* Viewport height */
object-fit: contain;
}
p,
li {
font-size: var(--font-size);
line-height: 1.2;
p {
font-size: 6rem;
line-height: 1;
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
text-wrap: balance;
max-width: 90vw;
word-break: break-word;
margin: 0 0 calc(var(--font-size) * 0.4) 0;
}
ul,
ol {
padding-left: calc(var(--font-size)*1.5);
display: block;
}
</style>
<script>
@@ -80,7 +59,6 @@ templ indexTemplate() {
<div hx-get="/splash" hx-trigger="load"></div>
<main hx-ext="sse" sse-connect="/sse" sse-swap="message"></main>
</bod>
</html>
}
@@ -94,27 +72,10 @@ templ imageTemplate(path string) {
<img src={ "/api/file/" + path }/>
}
templ deviceInfoTemplate(ip string, mac string, showQR bool) {
<div style="width: 100vw; height: 100vh; display: flex; flex-direction: row; justify-content: space-between;">
<div
style="display: flex; flex-direction: column; gap: 1rem; justify-content: end; padding: 2rem; font-size: 4rem;">
templ deviceInfoTemplate(ip string, mac string) {
<p>
{ ip }
<br/>
<span style="text-transform: uppercase;">{ mac }</span>
</div>
if showQR {
<div style="display: flex; justify-content: end; align-items: end; padding: 2rem;">
<div style="padding: 1rem; background-color: var(--foreground-color); border-radius: 1rem;">
<img style="height: 30vh; width: auto; image-rendering: pixelated; image-rendering: crisp-edges;"
src={ "/qr?data=http://" + ip + ":8080" } alt="QR-Code" />
</div>
</div>
}
</div>
<style>
:root {
--splash-bg: transparent !important;
--splash-fade-out-state: paused !important;
--background-color: oklch(21.6% 0.006 56.043);
}
</style>
</p>
}
-1
View File
@@ -1 +0,0 @@
(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i<r.length;i++){const u=r[i].trim();const c=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(u,c);return}if(!g.triggerEvent(n,"htmx:sseBeforeMessage",e)){return}f(n,e.data);g.triggerEvent(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=c;a.addEventListener(u,c)}}if(g.getAttributeValue(n,"hx-trigger")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var o=g.getTriggerSpecs(n);o.forEach(function(t){if(t.trigger.slice(0,4)!=="sse:"){return}var r=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(t.trigger.slice(4),r)}htmx.trigger(n,t.trigger,e);htmx.trigger(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=r;a.addEventListener(t.trigger.slice(4),r)})}}function i(e,t){if(e==null){return null}if(g.getAttributeValue(e,"sse-connect")){var r=g.getAttributeValue(e,"sse-connect");if(r==null){return}n(e,r,t)}a(e)}function n(r,e,n){var s=htmx.createEventSource(e);s.onerror=function(e){g.triggerErrorEvent(r,"htmx:sseError",{error:e,source:s});if(l(r)){return}if(s.readyState===EventSource.CLOSED){n=n||0;n=Math.max(Math.min(n*2,128),1);var t=n*500;window.setTimeout(function(){i(r,n)},t)}};s.onopen=function(e){g.triggerEvent(r,"htmx:sseOpen",{source:s});if(n&&n>0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e<t.length;e++){a(t[e])}n=0}};g.getInternalData(r).sseEventSource=s;var t=g.getAttributeValue(r,"sse-close");if(t){s.addEventListener(t,function(){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"message"});s.close()})}}function l(e){if(!g.bodyContains(e)){var t=g.getInternalData(e).sseEventSource;if(t!=undefined){g.triggerEvent(e,"htmx:sseClose",{source:t,type:"nodeMissing"});t.close();return true}}return false}function f(t,r){g.withExtensions(t,function(e){r=e.transformResponse(r,null,t)});var e=g.getSwapSpecification(t);var n=g.getTarget(t);g.swap(n,r,e,{contextElement:t})}function v(e){return g.getInternalData(e).sseEventSource!=null}})();
File diff suppressed because one or more lines are too long
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"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
@@ -1,30 +0,0 @@
{
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";
};
};
}
-164
View File
@@ -1,164 +0,0 @@
{
inputs,
config,
pkgs,
...
}: {
imports = [
inputs.home-manager.nixosModules.default
./hardware-configuration.nix
];
## System Config
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
hardware.steam-hardware.enable = true;
networking.networkmanager.enable = true;
nix.settings.experimental-features = ["nix-command" "flakes"];
time.timeZone = "Europe/Berlin";
i18n.defaultLocale = "en_US.UTF-8";
i18n.extraLocaleSettings = {
LC_ADDRESS = "de_DE.UTF-8";
LC_IDENTIFICATION = "de_DE.UTF-8";
LC_MEASUREMENT = "de_DE.UTF-8";
LC_MONETARY = "de_DE.UTF-8";
LC_NAME = "de_DE.UTF-8";
LC_NUMERIC = "de_DE.UTF-8";
LC_PAPER = "de_DE.UTF-8";
LC_TELEPHONE = "de_DE.UTF-8";
LC_TIME = "de_DE.UTF-8";
};
services.xserver.enable = true;
services.xserver.displayManager.lightdm.enable = true;
services.xserver.desktopManager.xfce.enable = true;
services.xserver.xkb = {
layout = "de";
variant = "";
};
console.keyMap = "de";
services.pulseaudio.enable = false;
security.rtkit.enable = true;
services.pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
};
users.users.mudics = {
isNormalUser = true;
description = "mudics";
extraGroups = ["networkmanager" "wheel"];
};
nixpkgs.config.allowUnfree = true;
system.stateVersion = "25.05"; # Don't change
## User Config
programs.nix-ld = {
enable = true;
libraries = with pkgs; [
stdenv.cc.cc
zlib
];
};
services.displayManager.autoLogin = {
enable = true;
user = "mudics";
};
networking.hostName = "mudics";
environment.systemPackages = with pkgs; [
# Programs
libreoffice
#rustdesk
ungoogled-chromium
xfce.thunar-archive-plugin
git
nushell
unzip
iputils
xreader
# Libraries
imagemagick
ffmpeg
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 = {
wantedBy = ["multi-user.target"];
after = ["network-online.target"];
wants = ["network-online.target"];
path = config.environment.systemPackages;
script = "nu ${./update.sh}";
serviceConfig = {
WorkingDirectory = "/home/mudics/mudics";
User = "mudics";
Type = "oneshot";
};
};
systemd.services.run-mudics = {
wantedBy = ["default.target"];
after = ["update-mudics.service" "graphical.target"];
wants = ["graphical.target"];
path = config.environment.systemPackages;
script = "./plg-mudics-display";
serviceConfig = {
WorkingDirectory = "/home/mudics/mudics";
User = "mudics";
Type = "simple";
};
environment = {
DISPLAY = ":0";
XDG_RUNTIME_DIR = "/run/user/1000";
};
};
systemd.services.build-mudics-system = {
wantedBy = ["default.target"];
after = ["update-mudics.service"];
path = with pkgs; [nixos-rebuild];
script = "nixos-rebuild boot --flake .#mudics";
serviceConfig = {
WorkingDirectory = "/home/mudics/mudics/nixos";
};
};
systemd.tmpfiles.rules = [
"d /home/mudics/mudics 0755 mudics - -"
];
networking.firewall = {
enable = true;
allowedTCPPorts = [
1323 # display
8080 # control
];
};
}
-49
View File
@@ -1,49 +0,0 @@
{
"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": {
"locked": {
"lastModified": 1761999846,
"narHash": "sha256-IYlYnp4O4dzEpL77BD/lj5NnJy2J8qbHkNSFiPBCbqo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3de8f8d73e35724bf9abef41f1bdbedda1e14a31",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-27
View File
@@ -1,27 +0,0 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
home-manager = {
url = "github:nix-community/home-manager/release-25.05";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
self,
nixpkgs,
...
} @ inputs: {
nixosConfigurations.mudics = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
inherit inputs;
};
modules = [
./configuration.nix
inputs.home-manager.nixosModules.default
];
};
};
}
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env nu
print "Checking if we have an internet connection ..."
if (ping google.com -c 5 -W 10 -s 1400 | complete | get exit_code) != 0 {
print "No internet connection. Exiting."
exit 1
}
print "Checking for new version of PLG-MuDiCS ..."
if (new_version_available) {
print "New version available. Trying to update ..."
get_new_display_file
get_new_nixos_config
}
print "Done"
def get_new_nixos_config [] {
let temp_folder_path = (mktemp -d "nixos-temp-XXXXXX")
let temp_file_path = (mktemp "nixos-temp-XXXXXX")
let nixos_config_path = "nixos"
http get https://github.com/PLG-Development/PLG-MuDiCS/releases/latest/download/nixos.zip | save -p -f $temp_file_path
unzip $temp_file_path -d $temp_folder_path
rm -rf $nixos_config_path
mv $temp_folder_path $nixos_config_path
rm $temp_file_path
cp /etc/nixos/hardware-configuration.nix $"($nixos_config_path)/hardware-configuration.nix"
}
def get_new_display_file [] {
let temp_file_path = (mktemp "display-temp-XXXXXX")
http get https://github.com/PLG-Development/PLG-MuDiCS/releases/latest/download/plg-mudics-display | save -p -f $temp_file_path
chmod +x $temp_file_path
mv $temp_file_path plg-mudics-display
}
def new_version_available [] {
let file_path = "version.json"
if not ($file_path | path exists) {
{ "version": ""} | to json | save version.json
}
let current_version = open $file_path | get version
let new_version = http get https://api.github.com/repos/PLG-Development/PLG-MuDiCS/releases/latest --max-time 5sec | get tag_name
# TODO: only write when all operations were successful
{ "version": $new_version} | to json | save version.json -f
if $current_version == $new_version {
false
} else {
true
}
}
-114
View File
@@ -1,114 +0,0 @@
{
"Escape": "VK_ESC",
"Digit1": "VK_1",
"Digit2": "VK_2",
"Digit3": "VK_3",
"Digit4": "VK_4",
"Digit5": "VK_5",
"Digit6": "VK_6",
"Digit7": "VK_7",
"Digit8": "VK_8",
"Digit9": "VK_9",
"Digit0": "VK_0",
"KeyQ": "VK_Q",
"KeyW": "VK_W",
"KeyE": "VK_E",
"KeyR": "VK_R",
"KeyT": "VK_T",
"KeyY": "VK_Y",
"KeyU": "VK_U",
"KeyI": "VK_I",
"KeyO": "VK_O",
"KeyP": "VK_P",
"KeyA": "VK_A",
"KeyS": "VK_S",
"KeyD": "VK_D",
"KeyF": "VK_F",
"KeyG": "VK_G",
"KeyH": "VK_H",
"KeyJ": "VK_J",
"KeyK": "VK_K",
"KeyL": "VK_L",
"KeyZ": "VK_Z",
"KeyX": "VK_X",
"KeyC": "VK_C",
"KeyV": "VK_V",
"KeyB": "VK_B",
"KeyN": "VK_N",
"KeyM": "VK_M",
"F1": "VK_F1",
"F2": "VK_F2",
"F3": "VK_F3",
"F4": "VK_F4",
"F5": "VK_F5",
"F6": "VK_F6",
"F7": "VK_F7",
"F8": "VK_F8",
"F9": "VK_F9",
"F10": "VK_F10",
"F11": "VK_F11",
"F12": "VK_F12",
"F13": "VK_F13",
"F14": "VK_F14",
"F15": "VK_F15",
"F16": "VK_F16",
"F17": "VK_F17",
"F18": "VK_F18",
"F19": "VK_F19",
"F20": "VK_F20",
"F21": "VK_F21",
"F22": "VK_F22",
"F23": "VK_F23",
"F24": "VK_F24",
"NumLock": "VK_NUMLOCK",
"ScrollLock": "VK_SCROLLLOCK",
"CapsLock": "VK_CAPSLOCK",
"Minus": "VK_SP2",
"Equal": "VK_SP3",
"Backspace": "VK_BACKSPACE",
"Tab": "VK_TAB",
"BracketLeft": "VK_SP4",
"BracketRight": "VK_SP5",
"Enter": "VK_ENTER",
"Semicolon": "VK_SP6",
"Quote": "VK_SP7",
"Backquote": "VK_SP1",
"Backslash": "VK_SP8",
"Comma": "VK_SP9",
"Period": "VK_SP10",
"Slash": "VK_SP11",
"IntlBackslash": "VK_SP12",
"NumpadMultiply": "VK_KPASTERISK",
"NumpadDivide": "VK_KPSLASH",
"NumpadAdd": "VK_KPPLUS",
"NumpadSubtract": "VK_KPMINUS",
"NumpadDecimal": "VK_KPDOT",
"NumpadEnter": "VK_KPENTER",
"Space": "VK_SPACE",
"Numpad0": "VK_KP0",
"Numpad1": "VK_KP1",
"Numpad2": "VK_KP2",
"Numpad3": "VK_KP3",
"Numpad4": "VK_KP4",
"Numpad5": "VK_KP5",
"Numpad6": "VK_KP6",
"Numpad7": "VK_KP7",
"Numpad8": "VK_KP8",
"Numpad9": "VK_KP9",
"PageUp": "VK_PAGEUP",
"PageDown": "VK_PAGEDOWN",
"End": "VK_END",
"Home": "VK_HOME",
"ArrowLeft": "VK_LEFT",
"ArrowUp": "VK_UP",
"ArrowRight": "VK_RIGHT",
"ArrowDown": "VK_DOWN",
"PrintScreen": "VK_PRINT",
"Insert": "VK_INSERT",
"Delete": "VK_DELETE",
"Help": "VK_HELP",
"BrowserBack": "VK_BACK",
"Pause": "VK_PAUSE",
"Lang1": "VK_HANGUEL",
"Lang2": "VK_HANJA"
}
-34
View File
@@ -2,10 +2,8 @@ package shared
import (
"bytes"
"context"
"errors"
"os/exec"
"time"
)
type CommandResponse struct {
@@ -14,11 +12,7 @@ type CommandResponse struct {
ExitCode int `json:"exitCode"`
}
type ErrorResponse struct {
Description string `json:"description"`
}
var BadRequestDescription string = "Request uses invalid JSON syntax or does not follow request schema."
func RunShellCommand(cmd *exec.Cmd) CommandResponse {
var stdout, stderr bytes.Buffer
@@ -26,34 +20,6 @@ func RunShellCommand(cmd *exec.Cmd) CommandResponse {
cmd.Stderr = &stderr
err := cmd.Run()
return parseCmdResult(cmd, stdout, stderr, err)
}
func RunShellCommandNonBlocking(cmd *exec.Cmd) CommandResponse {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Start()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-ctx.Done():
return CommandResponse{}
case err := <-done:
return parseCmdResult(cmd, stdout, stderr, err)
}
}
func parseCmdResult(cmd *exec.Cmd, stdout, stderr bytes.Buffer, err error) CommandResponse {
commandOutput := CommandResponse{
Stdout: stdout.String(),
Stderr: stderr.String(),
+29 -17
View File
@@ -40,7 +40,15 @@
<text class="splash-text" x="850" y="66.25" font-size="90">S</text>
<!-- Controller -->
<rect x="325" y="350" width="300" height="168.75" rx="8" ry="8" class="splash-control-monitor" />
<rect
x="325"
y="350"
width="300"
height="168.75"
rx="8"
ry="8"
class="splash-control-monitor"
/>
<rect x="335" y="390" width="135" height="120" rx="8" ry="8" class="splash-other-button" />
<rect x="480" y="390" width="135" height="55" rx="8" ry="8" class="splash-other-button" />
<rect x="480" y="455" width="135" height="55" rx="8" ry="8" class="splash-start-button" />
@@ -51,15 +59,30 @@
<path class="window-controls" fill="none" d="M 575 360 H 590 V 375 H 575 V 360" />
<path class="window-controls" d="M 550 375 H 565" />
<svg class="cursor" width="50" height="50" viewBox="0 0 24 24" x="510" y="480" aria-hidden="true">
<g stroke="#ffffff" fill="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg
class="cursor"
width="50"
height="50"
viewBox="0 0 24 24"
x="510"
y="480"
aria-hidden="true"
>
<g
stroke="#ffffff"
fill="#000000"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="#000000" d="M 6.5 10 H 18 V 19 H 6" />
<path d="M22 14a8 8 0 0 1-8 8" />
<path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2" />
<path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1" />
<path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10" />
<path
d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" />
d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"
/>
</g>
</svg>
</svg>
@@ -67,9 +90,6 @@
<style>
:root {
--splash-fade-out-delay: 0s;
--splash-fade-out-state: running;
--base-stroke: #444444;
--base-stroke-active: #109e00;
@@ -98,20 +118,13 @@
align-items: center;
justify-content: center;
z-index: 9999;
/* animation: splash-fade 1s ease 4s forwards; */
animation: splash-fade 1s ease 4s forwards;
height: 100vh;
width: 100vw;
animation-name: splash-fade;
animation-duration: 1s;
animation-timing-function: ease;
animation-fill-mode: forwards;
animation-delay: var(--splash-fade-out-delay);
animation-play-state: var(--splash-fade-out-state);
}
#splash-svg {
max-height: 50vh;
max-height: 40vh;
max-width: 80vw;
opacity: 0;
animation: fade-in 1s ease 500ms forwards;
@@ -170,7 +183,6 @@
dominant-baseline: middle;
fill: var(--monitor-dark);
user-select: none;
font-family: sans-serif;
}
.splash-monitor {
-34
View File
@@ -1,34 +0,0 @@
{
".mp4": {
"display_name": "MP4",
"mime_type": "video/mp4"
},
".jpg": {
"display_name": "JPG",
"mime_type": "image/jpg"
},
".jpeg": {
"display_name": "JPG",
"mime_type": "image/jpeg"
},
".png": {
"display_name": "PNG",
"mime_type": "image/png"
},
".gif": {
"display_name": "GIF",
"mime_type": "image/gif"
},
".pptx": {
"display_name": "PPTX",
"mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
},
".odp": {
"display_name": "ODP",
"mime_type": "application/vnd.oasis.opendocument.presentation"
},
".pdf": {
"display_name": "PDF",
"mime_type": "application/pdf"
}
}