Release 202412302114
4
.eslintrc.mjs
Normal file
@ -0,0 +1,4 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
|
||||
};
|
||||
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
/public/build
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
prisma/migrations
|
||||
.react-email
|
||||
.DS_Store
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
130
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,130 @@
|
||||
CODE_OF_CONDUCT.md
|
||||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
support@jetkvm.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
339
LICENSE
Normal file
@ -0,0 +1,339 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
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
|
||||
this service 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.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
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
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the 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 a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE 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.
|
||||
|
||||
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
|
||||
convey 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 General Public License as published by
|
||||
the Free Software Foundation; either version 2 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
37
README.md
Normal file
@ -0,0 +1,37 @@
|
||||
<div align="center">
|
||||
<img alt="JetKVM logo" src="https://jetkvm.com/logo-blue.png" height="28">
|
||||
|
||||
### Website & Documentation
|
||||
|
||||
[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/website/issues) | [Docs](https://jetkvm.com/docs)
|
||||
|
||||
[](https://twitter.com/jetkvm)
|
||||
|
||||
</div>
|
||||
|
||||
JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively.
|
||||
|
||||
JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community! Whether it's improving the firmware, adding new features, or enhancing documentation, your input is valuable. We also have some rules and taboos here, so please read this page and our [Code of Conduct](/CODE_OF_CONDUCT.md) carefully.
|
||||
|
||||
## I need help
|
||||
|
||||
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW).
|
||||
|
||||
## I want to report an issue
|
||||
|
||||
If you've found an issue and want to report it, please check our [Issues](https://github.com/jetkvm/cloud-api/issues) page. Make sure the description contains information about the firmware version you're using, your platform, and a clear explanation of the steps to reproduce the issue.
|
||||
|
||||
## Development
|
||||
|
||||
This project is built with Remix and Tailwind CSS.
|
||||
|
||||
To start the development server, run:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
11
app/assets/kickstarter-icon.svg
Normal file
|
After Width: | Height: | Size: 28 KiB |
31
app/components/Alert.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
||||
import Card from "./Card";
|
||||
|
||||
export default function Alert({
|
||||
headline,
|
||||
description,
|
||||
BtnElm,
|
||||
}: {
|
||||
headline: string;
|
||||
description: string | React.ReactNode;
|
||||
BtnElm?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-yellow-50 px-5 py-6 outline-yellow-700/40">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<ExclamationCircleIcon
|
||||
className="h-5 w-5 shrink-0 text-black"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-base font-bold leading-none text-black">{headline}</h3>
|
||||
<div className="text-sm leading-none text-yellow-900">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{BtnElm && BtnElm}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
200
app/components/Button.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import React from "react";
|
||||
import ExtLink from "~/components/ExtLink";
|
||||
import type { LinkProps } from "@remix-run/react";
|
||||
import { Link, useNavigation } from "@remix-run/react";
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { cva, cx } from "~/cva.config";
|
||||
|
||||
const sizes = {
|
||||
XS: "h-[26.5px] px-2 text-xs",
|
||||
SM: "h-[36px] px-3 text-[13px]",
|
||||
MD: "h-[40px] px-3.5 text-sm",
|
||||
LG: "h-[48px] px-4 text-base",
|
||||
XL: "h-[56px] px-5 text-base",
|
||||
};
|
||||
|
||||
const themes = {
|
||||
primary:
|
||||
"bg-blue-700 hover:bg-blue-800 active:bg-blue-900 border border-blue-700/80 text-white shadow",
|
||||
danger:
|
||||
"bg-red-600 hover:bg-red-700 active:bg-red-800 text-white border-red-700 hover:border-red-800 group-hover:border-red-800 shadow-sm group-focus:ring-red-700 shadow-red-200/80",
|
||||
light:
|
||||
"text-black bg-white group-hover:bg-blue-50/80 group-active:bg-blue-100/60 border-slate-800/30 shadow group-disabled:group-hover:bg-white",
|
||||
lightDanger:
|
||||
"text-black bg-white group-hover:bg-red-50/80 group-active:bg-red-100/60 text-black border-red-400/60 shadow-sm group-focus:ring-red-700",
|
||||
blank:
|
||||
"text-black bg-white/0 group-hover:bg-white active:bg-slate-100/80 text-black border-transparent group-hover:border-slate-800/30 hover:shadow",
|
||||
};
|
||||
|
||||
const btnVariants = cva({
|
||||
base: "outline-none font-display text-center font-semibold justify-center items-center duration-75 shrink-0 transition-colors leading-tight border rounded-md select-none group-focus:outline-none group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700 group-disabled:opacity-60 group-disabled:pointer-events-none",
|
||||
variants: {
|
||||
size: sizes,
|
||||
theme: themes,
|
||||
},
|
||||
});
|
||||
|
||||
const iconVariants = cva({
|
||||
variants: {
|
||||
size: {
|
||||
XS: "h-3",
|
||||
SM: "h-4",
|
||||
MD: "h-5",
|
||||
LG: "h-6",
|
||||
XL: "h-6",
|
||||
},
|
||||
theme: {
|
||||
primary: "text-white",
|
||||
danger: "text-white",
|
||||
light: "text-slate-700",
|
||||
lightDanger: "text-slate-700",
|
||||
blank: "text-slate-700",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type ButtonContentPropsType = {
|
||||
text?: string | React.ReactNode;
|
||||
LeadingIcon?: React.FC<{ className: string | undefined }>;
|
||||
TrailingIcon?: React.FC<{ className: string | undefined }>;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
textAlign?: "left" | "center" | "right";
|
||||
size: keyof typeof sizes;
|
||||
theme: keyof typeof themes;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function ButtonContent(props: ButtonContentPropsType) {
|
||||
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =
|
||||
props;
|
||||
|
||||
// Based on the size prop, we'll use the corresponding variant classnames
|
||||
const iconClassName = iconVariants(props);
|
||||
const btnClassName = btnVariants(props);
|
||||
return (
|
||||
<div className={cx(className, fullWidth ? "flex" : "inline-flex", btnClassName)}>
|
||||
<div
|
||||
className={cx(
|
||||
"flex w-full min-w-0 items-center gap-x-1.5 text-center",
|
||||
textAlign === "left" ? "!text-left" : "",
|
||||
textAlign === "center" ? "!text-center" : "",
|
||||
textAlign === "right" ? "!text-right" : "",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div>
|
||||
<LoadingSpinner className={cx(iconClassName, "animate-spin")} />
|
||||
</div>
|
||||
) : (
|
||||
LeadingIcon && (
|
||||
<LeadingIcon className={cx(iconClassName, "shrink-0 justify-start")} />
|
||||
)
|
||||
)}
|
||||
|
||||
{text && typeof text === "string" ? (
|
||||
<span className="relative w-full truncate">{text}</span>
|
||||
) : (
|
||||
text
|
||||
)}
|
||||
|
||||
{TrailingIcon && (
|
||||
<TrailingIcon className={cx(iconClassName, "shrink-0 justify-end")} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonPropsType = Pick<
|
||||
JSX.IntrinsicElements["button"],
|
||||
"type" | "disabled" | "onClick" | "name" | "value" | "formNoValidate" | "onMouseLeave"
|
||||
> &
|
||||
React.ComponentProps<typeof ButtonContent> & { fetcher?: any };
|
||||
export const Button = ({
|
||||
type,
|
||||
disabled,
|
||||
onClick,
|
||||
formNoValidate,
|
||||
loading,
|
||||
fetcher,
|
||||
...props
|
||||
}: ButtonPropsType) => {
|
||||
const classes = cx(
|
||||
"group outline-none",
|
||||
props.fullWidth ? "w-full" : "",
|
||||
loading ? "pointer-events-none" : "",
|
||||
);
|
||||
const navigation = useNavigation();
|
||||
let loader = fetcher ? fetcher : navigation;
|
||||
return (
|
||||
<button
|
||||
formNoValidate={formNoValidate}
|
||||
className={classes}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
name={props.name}
|
||||
value={props.value}
|
||||
>
|
||||
<ButtonContent
|
||||
{...props}
|
||||
loading={
|
||||
loading ??
|
||||
(type === "submit" &&
|
||||
(loader.state === "submitting" || loader.state === "loading") &&
|
||||
loader.formMethod?.toLowerCase() === "post")
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
type LinkPropsType = Pick<LinkProps, "to"> &
|
||||
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
|
||||
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
|
||||
const classes = twMerge(
|
||||
cx(
|
||||
"group outline-none",
|
||||
props.disabled ? "pointer-events-none !opacity-70" : "",
|
||||
props.fullWidth ? "w-full" : "",
|
||||
props.loading ? "pointer-events-none" : "",
|
||||
props.className,
|
||||
),
|
||||
);
|
||||
|
||||
if (to.toString().startsWith("http")) {
|
||||
return (
|
||||
<ExtLink href={to.toString()} className={classes}>
|
||||
<ButtonContent {...props} />
|
||||
</ExtLink>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Link to={to} className={classes}>
|
||||
<ButtonContent {...props} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type LabelPropsType = Pick<HTMLLabelElement, "htmlFor"> &
|
||||
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
|
||||
export const LabelButton = ({ htmlFor, ...props }: LabelPropsType) => {
|
||||
const classes = cx(
|
||||
"group outline-none block cursor-pointer",
|
||||
props.disabled ? "pointer-events-none !opacity-70" : "",
|
||||
props.fullWidth ? "w-full" : "",
|
||||
props.loading ? "pointer-events-none" : "",
|
||||
props.className,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={htmlFor} className={classes}>
|
||||
<ButtonContent {...props} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
app/components/Card.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { cx } from "../cva.config";
|
||||
|
||||
type CardPropsType = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const GridCard = ({
|
||||
children,
|
||||
cardClassName,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cardClassName?: string;
|
||||
}) => {
|
||||
return (
|
||||
<Card className={cx("overflow-hidden", cardClassName)}>
|
||||
<div className="relative h-full">
|
||||
<div className="grid-card absolute inset-0 z-0 h-full w-full" />
|
||||
<div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%]" />
|
||||
<div className="isolate h-full">{children}</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Card({ children, className }: CardPropsType) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
clsx(
|
||||
"w-full rounded-md bg-white shadow outline outline-1 outline-gray-800/[20%]",
|
||||
className,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
app/components/Container.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import React from "react";
|
||||
|
||||
function Container({ children, className }: { children: any; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
twMerge(
|
||||
"mx-auto h-full w-full px-4 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl",
|
||||
className,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Article({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="grid w-full grid-cols-12">
|
||||
<div className="col-span-12 xl:col-span-11 xl:col-start-2">{children}</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Container, {
|
||||
Article,
|
||||
});
|
||||
26
app/components/EmptyCard.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { GridCard } from "~/components/Card";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
IconElm?: React.FC<any>;
|
||||
headline: string;
|
||||
description?: string;
|
||||
BtnElm?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function EmptyCard({ IconElm, headline, description, BtnElm }: Props) {
|
||||
return (
|
||||
<GridCard>
|
||||
<div className="flex min-h-[256px] w-full flex-col items-center justify-center gap-y-4 px-4 py-5 text-center">
|
||||
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
|
||||
<div className="space-y-2">
|
||||
{IconElm && <IconElm className="mx-auto h-6 w-6 text-blue-600" />}
|
||||
<h4 className="text-base font-bold leading-none">{headline}</h4>
|
||||
</div>
|
||||
<p className="mx-auto text-sm text-slate-600">{description}</p>
|
||||
</div>
|
||||
{BtnElm}
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
28
app/components/ExtLink.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function ExtLink({
|
||||
className,
|
||||
href,
|
||||
id,
|
||||
target,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
href: string;
|
||||
id?: string;
|
||||
target?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
className={clsx(className)}
|
||||
target={target ?? "_blank"}
|
||||
id={id}
|
||||
rel="noopener noreferrer"
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
152
app/components/Icons.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { cx } from "~/cva.config";
|
||||
|
||||
export const YCombinatorIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
viewBox="0 0 108 32"
|
||||
fill="none"
|
||||
className={clsx(className, "shrink-0")}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M36.5438 3.46526C37.0072 3.22273 37.2904 2.80697 37.2904 2.2353C37.2904 1.2652 36.4838 0.54628 35.4197 0.54628H32.9141V6.4362H35.5227C36.5867 6.4362 37.3848 5.70863 37.3848 4.73852C37.3848 4.12354 37.0587 3.69912 36.5438 3.46526ZM35.4197 1.41245C36.029 1.41245 36.3894 1.75891 36.3894 2.2353C36.3894 2.70303 36.0204 3.05816 35.4197 3.05816H33.8151V1.41245H35.4197ZM35.5227 5.57004H33.8151V3.92432H35.5227C36.1234 3.92432 36.4838 4.26213 36.4838 4.73852C36.4838 5.21491 36.1234 5.57004 35.5227 5.57004ZM40.2523 2.0101C39.6087 2.0101 38.9394 2.37389 38.5704 2.91957L39.2654 3.41329C39.4714 3.05816 39.8919 2.79831 40.3209 2.79831C40.8443 2.79831 41.1018 3.17076 41.1704 3.57786L39.8318 3.83771C39.0767 3.98496 38.4159 4.38339 38.4159 5.21491C38.4159 6.04643 39.0681 6.52282 39.8232 6.52282C40.4239 6.52282 40.9302 6.21966 41.1876 5.83855V6.4362H42.0457V3.7944C42.0457 2.72036 41.3249 2.0101 40.2523 2.0101ZM40.0291 5.72595C39.5915 5.72595 39.274 5.50075 39.274 5.18026C39.274 4.84246 39.6258 4.66057 39.9948 4.57395L41.1876 4.33142V4.64324C41.1876 5.31019 40.6384 5.72595 40.0291 5.72595ZM46.5353 5.06766C46.2693 5.4661 45.8317 5.69996 45.3769 5.69996C44.6218 5.69996 44.0726 5.06766 44.0726 4.26213C44.0726 3.4566 44.6218 2.82429 45.3769 2.82429C45.8317 2.82429 46.2693 3.05816 46.5353 3.4566L47.2561 3.03217C46.8443 2.40854 46.1492 2.00144 45.3683 2.00144C44.1241 2.00144 43.2145 3.00619 43.2145 4.26213C43.2145 5.51807 44.1241 6.52282 45.3683 6.52282C46.1492 6.52282 46.8443 6.11572 47.2561 5.49208L46.5353 5.06766ZM52.2435 2.10538H51.1108L49.2401 4.07157V0.373047H48.382V6.4362H49.2401V5.15428L49.9266 4.45269L51.2395 6.4362H52.2435L50.553 3.82038L52.2435 2.10538ZM56.6974 3.89834C56.5515 2.57311 55.6505 2.01876 54.7323 2.01876C53.4881 2.01876 52.6643 3.02351 52.6643 4.27079C52.6643 5.51807 53.4881 6.52282 54.7323 6.52282C55.5132 6.52282 56.2512 6.11572 56.6202 5.49208L55.8994 5.06766C55.6934 5.44011 55.2386 5.69996 54.7238 5.69996C54.0888 5.69996 53.6769 5.31885 53.5653 4.66923H56.6974C56.7231 4.39205 56.7231 4.1322 56.6974 3.89834ZM54.7066 2.84162C55.2386 2.84162 55.8135 3.14478 55.8393 3.88968H53.5567C53.6683 3.24005 54.0544 2.84162 54.7066 2.84162ZM60.9571 0.373047V2.72902C60.7169 2.32192 60.2621 2.01876 59.6443 2.01876C58.5459 2.01876 57.7135 2.95422 57.7049 4.27079C57.6964 5.61335 58.5459 6.52282 59.6357 6.52282C60.2878 6.52282 60.734 6.19368 60.9571 5.76059V6.4362H61.8152V0.373047H60.9571ZM59.7815 5.69996C59.035 5.69996 58.563 5.08499 58.563 4.27079C58.563 3.4566 59.035 2.84162 59.7815 2.84162C60.5281 2.84162 61 3.4566 61 4.27079C61 5.08499 60.5281 5.69996 59.7815 5.69996ZM70.2572 4.27079C70.2486 2.95422 69.4162 2.01876 68.3179 2.01876C67.7 2.01876 67.2452 2.32192 67.005 2.72902V0.373047H66.1469V6.4362H67.005V5.76059C67.2281 6.19368 67.6743 6.52282 68.3264 6.52282C69.4162 6.52282 70.2657 5.61335 70.2572 4.27079ZM68.1806 5.69996C67.434 5.69996 66.9621 5.08499 66.9621 4.27079C66.9621 3.4566 67.434 2.84162 68.1806 2.84162C68.9271 2.84162 69.3991 3.4566 69.3991 4.27079C69.3991 5.08499 68.9271 5.69996 68.1806 5.69996ZM74.5294 2.10538L73.2766 5.14562L71.938 2.10538H70.9941L72.8218 6.23699L72.0238 8.16853H72.9334L75.4133 2.10538H74.5294Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<mask
|
||||
id="mask0_2673_271"
|
||||
style={{ maskType: "luminance" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="9"
|
||||
width="108"
|
||||
height="23"
|
||||
>
|
||||
<path d="M0.367188 9.68408H107.898V31.3382H0.367188V9.68408Z" fill="white" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2673_271)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0.367188 31.3382H21.8197V9.68408H0.367188V31.3382Z"
|
||||
fill="#F05F22"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11.9642 21.5872V26.3985H10.1541V21.5872L5.53516 14.624H7.74073L11.0659 19.7669L14.3776 14.624H16.5832L11.9642 21.5872Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M34.3977 16.9462C32.4133 16.9462 30.9252 18.4958 30.9252 20.4989C30.9252 22.5019 32.4133 24.065 34.3977 24.065C35.6716 24.065 36.7775 23.4018 37.3742 22.2853L39.0772 23.3071C38.1251 24.9515 36.342 26.0207 34.3977 26.0207C31.3407 26.0139 28.9141 23.5642 28.9141 20.4989C28.9141 17.4267 31.3407 14.9771 34.3977 14.9771C36.362 14.9771 38.1251 16.0327 39.0772 17.6906L37.3742 18.7124C36.771 17.5958 35.6716 16.9462 34.3977 16.9462Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M45.6197 21.893C45.6197 20.6411 44.7144 19.6938 43.5481 19.6938C42.3482 19.6938 41.4429 20.6411 41.4429 21.893C41.4429 23.1449 42.3482 24.0922 43.5481 24.0922C44.6944 24.0922 45.6197 23.1449 45.6197 21.893ZM39.4922 21.893C39.4922 19.6329 41.2888 17.8193 43.5481 17.8193C45.7738 17.8193 47.5705 19.6329 47.5705 21.893C47.5705 24.1532 45.7738 25.9667 43.5481 25.9667C41.2888 25.9667 39.4922 24.1532 39.4922 21.893Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M60.2312 21.2431V25.7905H58.2869V21.5882C58.2869 20.4108 57.7173 19.6664 56.8592 19.6664C55.9808 19.6664 55.3372 20.4108 55.3372 21.5882V25.7905H53.4601V21.5882C53.4601 20.4108 52.8701 19.6664 52.0056 19.6664C51.1407 19.6664 50.5104 20.4108 50.5104 21.5882V25.7905H48.5664V18.0085H50.5104V19.0033C50.9866 18.2589 51.7239 17.792 52.6288 17.792C53.6145 17.792 54.3922 18.3536 54.8681 19.2266C55.3576 18.4484 56.2625 17.792 57.402 17.792C59.1117 17.792 60.2312 19.3145 60.2312 21.2431Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M67.1393 21.9739C67.1393 20.6747 66.3417 19.7408 65.2222 19.7408C64.1292 19.7408 63.3047 20.6273 63.3047 21.9536C63.3047 23.2732 64.1496 24.1664 65.2555 24.1664C66.3417 24.1596 67.1393 23.2461 67.1393 21.9739ZM65.6309 25.9935C64.5583 25.9935 63.7674 25.4994 63.3115 24.816V25.7904H61.3672V14.9634H63.3115V18.9559C63.7806 18.3198 64.5718 17.8596 65.5976 17.8596C67.5884 17.8596 69.1105 19.5919 69.1437 21.9265C69.1773 24.2882 67.622 25.9935 65.6309 25.9935Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M69.9509 25.7906H71.8952V18.0086H69.9509V25.7906ZM69.7031 15.8026C69.7031 15.1394 70.2527 14.6387 70.9163 14.6387C71.5598 14.6387 72.0962 15.1326 72.0962 15.8026C72.0962 16.4657 71.5598 16.9665 70.9163 16.9665C70.2595 16.9597 69.7031 16.4657 69.7031 15.8026Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M80.2741 21.2769V25.7905H78.397V21.6965C78.397 20.4446 77.747 19.6664 76.8085 19.6664C75.7759 19.6664 75.0451 20.6138 75.0451 21.7303V25.7905H73.168V18.0085H75.0451V18.9897C75.5681 18.2589 76.4062 17.792 77.3848 17.792C79.1146 17.792 80.2741 19.2807 80.2741 21.2769Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M86.1729 22.3396V22.1704L84.2089 22.5562C83.5517 22.7118 83.1495 23.0501 83.1495 23.5035C83.1495 23.984 83.5854 24.3426 84.3026 24.3223C85.3352 24.2885 86.1729 23.3817 86.1729 22.3396ZM88.05 21.0945V25.7907H86.1729V24.8907C85.6033 25.5133 84.7116 25.9734 83.7262 25.9937C82.4187 25.9937 81.2656 25.1276 81.2656 23.6389C81.2656 22.2313 82.3114 21.4261 83.8667 21.1148L86.126 20.6817C85.9719 20.0321 85.4825 19.5381 84.7384 19.5381C84.0008 19.5381 83.3104 20.005 82.8949 20.5938L81.4801 19.5584C82.2042 18.5366 83.4781 17.8531 84.7452 17.8531C86.6355 17.8396 88.05 19.1592 88.05 21.0945Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M92.0077 19.7679V23.0295C92.0077 23.6656 92.2827 23.9566 92.8523 23.9566H93.8176V25.7836H92.5841C90.9419 25.7836 90.1307 24.9716 90.1307 23.2663V19.7679H88.7227V18.0085H90.0634V16.0866L92.0077 15.4844V18.0085H93.8176V19.7679H92.0077Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M100.58 21.893C100.58 20.6411 99.6754 19.6938 98.5087 19.6938C97.3088 19.6938 96.4039 20.6411 96.4039 21.893C96.4039 23.1449 97.3088 24.0922 98.5087 24.0922C99.6554 24.0922 100.58 23.1449 100.58 21.893ZM94.4531 21.893C94.4531 19.6329 96.2498 17.8193 98.5087 17.8193C100.734 17.8193 102.531 19.6329 102.531 21.893C102.531 24.1532 100.734 25.9667 98.5087 25.9667C96.2498 25.9667 94.4531 24.1532 94.4531 21.893Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M107.893 17.8394V19.7273C106.156 19.7273 105.412 20.58 105.412 21.6965V25.7905H103.535V18.0085H105.412V18.983C105.942 18.286 106.793 17.8394 107.893 17.8394Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DiscordIcon = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
className={cx(className, "shrink-0")}
|
||||
viewBox="0 0 33 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14.5906 5.75977L13.437 5.87017C10.2213 6.17833 7.80304 8.11625 7.7016 8.19849L7.51056 8.35369L7.372 8.55721C7.26032 8.72137 4.62928 12.665 4.18192 20.1831L4.1416 20.8599L4.58256 21.3751C7.54384 24.8353 11.9502 24.9018 12.1365 24.9028L12.9893 24.9079L13.4968 24.2225L14.1918 23.2839C14.9314 23.3834 15.7 23.4327 16.4994 23.4327C17.299 23.4327 18.0674 23.3831 18.8069 23.2839L19.5019 24.2225L20.0094 24.9079L20.8622 24.9028C21.0485 24.9018 25.4546 24.8353 28.4162 21.3751L28.8571 20.8599L28.8168 20.1831C28.3694 12.665 25.7384 8.72137 25.6267 8.55721L25.4824 8.34537L25.2814 8.18601C25.1746 8.10121 22.6258 6.10601 19.5304 5.86729L18.3976 5.77993L17.8949 6.79849L17.8482 6.89289C17.4206 6.85993 16.9717 6.84233 16.4994 6.84233C16.027 6.84233 15.5781 6.85993 15.1506 6.89289L15.1038 6.79849L14.5906 5.75977Z"
|
||||
fill="#4343BF"
|
||||
/>
|
||||
<path
|
||||
d="M24.2373 9.50297C24.2373 9.50297 22.0197 7.74489 19.4008 7.54297L19.1646 8.02137C21.5326 8.60825 22.6187 9.44985 23.7534 10.4831C21.797 9.47129 19.8654 8.52313 16.4987 8.52313C13.132 8.52313 11.2005 9.47129 9.244 10.4831C10.3787 9.44985 11.6709 8.51577 13.8328 8.02137L13.5966 7.54297C10.8491 7.80601 8.76016 9.50297 8.76016 9.50297C8.76016 9.50297 6.28336 13.1414 5.8584 20.2831C8.35504 23.2002 12.1458 23.2233 12.1458 23.2233L12.9384 22.1526C11.5928 21.6786 10.0731 20.8326 8.76016 19.3033C10.3262 20.5039 12.6898 21.7532 16.4984 21.7532C20.307 21.7532 22.6709 20.5036 24.2366 19.3033C22.9237 20.8326 21.404 21.679 20.0584 22.1526L20.851 23.2233C20.851 23.2233 24.6418 23.2002 27.1384 20.2831C26.7141 13.141 24.2373 9.50297 24.2373 9.50297Z"
|
||||
fill="#4B4DFF"
|
||||
/>
|
||||
<path
|
||||
d="M12.6602 17.5991C13.7206 17.5991 14.5802 16.5963 14.5802 15.3591C14.5802 14.122 13.7206 13.1191 12.6602 13.1191C11.5998 13.1191 10.7402 14.122 10.7402 15.3591C10.7402 16.5963 11.5998 17.5991 12.6602 17.5991Z"
|
||||
fill="#EDF7F5"
|
||||
/>
|
||||
<path
|
||||
d="M20.3399 17.5991C21.4003 17.5991 22.2599 16.5963 22.2599 15.3591C22.2599 14.122 21.4003 13.1191 20.3399 13.1191C19.2795 13.1191 18.4199 14.122 18.4199 15.3591C18.4199 16.5963 19.2795 17.5991 20.3399 17.5991Z"
|
||||
fill="#EDF7F5"
|
||||
/>
|
||||
<path
|
||||
d="M5.81375 20.2305L5.77087 20.2625L2.79199 21.3437C2.89535 21.4858 5.08191 24.8289 10.353 25.8516L12.1965 23.1441C7.78911 22.8845 5.89887 20.3425 5.81375 20.2305Z"
|
||||
fill="#FF8405"
|
||||
/>
|
||||
<path
|
||||
d="M27.1523 20.2305L27.1951 20.2625L30.174 21.3437C30.0707 21.4858 27.8841 24.8289 22.6131 25.8516L20.7695 23.1441C25.1769 22.8845 27.0671 20.3425 27.1523 20.2305Z"
|
||||
fill="#FF8405"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const GitHubIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={clsx(className, "shrink-0")}
|
||||
>
|
||||
<path
|
||||
d="M12 2C6.475 2 2 6.59118 2 12.2539C2 16.7853 4.865 20.6279 8.8375 21.9823C9.3375 22.0788 9.52083 21.7618 9.52083 21.4892C9.52083 21.2457 9.5125 20.6006 9.50833 19.7461C6.72667 20.3647 6.14 18.3703 6.14 18.3703C5.685 17.1869 5.0275 16.8707 5.0275 16.8707C4.12167 16.235 5.0975 16.2478 5.0975 16.2478C6.10167 16.3196 6.62917 17.3039 6.62917 17.3039C7.52083 18.8719 8.97 18.4191 9.54167 18.1567C9.63167 17.4936 9.88917 17.0416 10.175 16.7853C7.95417 16.5289 5.62 15.6471 5.62 11.7181C5.62 10.5987 6.0075 9.68444 6.64917 8.96667C6.53667 8.70776 6.19917 7.66528 6.73667 6.2528C6.73667 6.2528 7.57417 5.97766 9.48667 7.30383C10.2867 7.07568 11.1367 6.96289 11.9867 6.95776C12.8367 6.96289 13.6867 7.07568 14.4867 7.30383C16.3867 5.97766 17.2242 6.2528 17.2242 6.2528C17.7617 7.66528 17.4242 8.70776 17.3242 8.96667C17.9617 9.68444 18.3492 10.5987 18.3492 11.7181C18.3492 15.6573 16.0117 16.5246 13.7867 16.7767C14.1367 17.0843 14.4617 17.7132 14.4617 18.6737C14.4617 20.046 14.4492 21.1483 14.4492 21.4816C14.4492 21.7507 14.6242 22.0711 15.1367 21.9686C19.1375 20.6236 22 16.7784 22 12.2539C22 6.59118 17.5225 2 12 2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
26
app/components/LoadingSpinner.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function LoadingSpinner({ className }: { className: string | undefined }) {
|
||||
return (
|
||||
<svg
|
||||
className={clsx(className, "flex-shrink-0 animate-spin p-[2px]")}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
55
app/components/Modal.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React, { Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function Modal({
|
||||
children,
|
||||
className,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500/75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-8 "
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="ease-in duration-300"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-8"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={clsx("pointer-events-none relative w-full sm:my-8", className)}
|
||||
>
|
||||
<div className="pointer-events-auto inline-block text-left">
|
||||
{children}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
30
app/components/NotFoundPage.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import { Links, Meta, Scripts } from "@remix-run/react";
|
||||
import EmptyCard from "~/components/EmptyCard";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<title>JetKVM - 404 Not found</title>
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{/* add the UI you want your accounts to see */}
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="w-full max-w-2xl">
|
||||
<EmptyCard
|
||||
IconElm={ExclamationTriangleIcon}
|
||||
headline="Not found"
|
||||
description="The page you were looking for does not exist."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
61
app/components/landingpage/Accordion.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import Card from "~/components/Card";
|
||||
import { ChevronRightIcon } from "@heroicons/react/16/solid";
|
||||
|
||||
type Props = { items: { title: string; content: string }[] };
|
||||
|
||||
function AccordionItem({ title, content }: { title: string; content: string }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<div onClick={() => setOpen(x => !x)}>
|
||||
<Card className="cursor-pointer">
|
||||
<div className="bg-grid-sm-blue-700/[2%]">
|
||||
<div className="bg-gradient-to-b from-transparent to-white pb-2 pt-3 transition-all duration-300 hover:to-blue-50/50">
|
||||
<dt>
|
||||
<div className="flex items-center gap-x-2 px-4 pb-1">
|
||||
<span className="flex items-center">
|
||||
<ChevronRightIcon
|
||||
className={clsx(
|
||||
"h-4 w-4 rotate-0 transform text-blue-700 transition duration-300",
|
||||
{
|
||||
"rotate-90": open,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div className="select-none font-display font-semibold">{title}</div>
|
||||
</div>
|
||||
</dt>
|
||||
<div className="flex">
|
||||
<div
|
||||
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
||||
className={clsx(
|
||||
"grid opacity-0 transition-all duration-300 ease-in-out",
|
||||
{ "opacity-100": open },
|
||||
)}
|
||||
>
|
||||
<div className="select-none overflow-hidden px-10 font-display text-base text-slate-600">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Accordion({ items }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map(({ title, content }) => (
|
||||
<div key={title + content}>
|
||||
<AccordionItem title={title} content={content} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/components/landingpage/DataTable.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
export default function DataTable({
|
||||
headline,
|
||||
data,
|
||||
}: {
|
||||
headline: string;
|
||||
data: { key: string; value: string }[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold">{headline}</h3>
|
||||
<div className="border-800/10 flex justify-between border-y py-4 font-display">
|
||||
{data?.map(x => (
|
||||
<div key={x.key}>
|
||||
<div className="text-xs text-gray-600">{x.key}</div>
|
||||
<div className="text-base font-medium text-black">{x.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
app/components/landingpage/DocsNavbar.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { MobileNavigation } from "~/components/landingpage/MobileNavigation";
|
||||
import Container from "~/components/Container";
|
||||
import { Search } from "~/components/landingpage/Search";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
|
||||
import { DiscordIcon } from "../Icons";
|
||||
|
||||
type HeaderProps = { navigation: any; isLoggedIn: boolean | undefined };
|
||||
|
||||
export function DocsNavbar({ navigation, isLoggedIn }: HeaderProps) {
|
||||
return (
|
||||
<div className="sticky top-0 z-50 select-none backdrop-blur">
|
||||
<div className="bg-grid relative isolate overflow-hidden bg-blue-700/90 transition-colors duration-300 hover:bg-blue-700">
|
||||
<div
|
||||
className="absolute inset-0 -z-20 overflow-hidden bg-grid-lg-blue-500/30"
|
||||
style={{
|
||||
maskImage:
|
||||
"radial-gradient(ellipse at center, rgba(0,0,0,1), transparent 75%",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 -z-10 overflow-hidden"
|
||||
style={{ backgroundImage: "url(/bg-noise.png)", backgroundRepeat: "repeat" }}
|
||||
/>
|
||||
<Link to="/" prefetch="intent">
|
||||
<Container>
|
||||
<div className="flex items-center gap-x-2 py-2">
|
||||
<ChevronLeftIcon className="w-4 text-white/90" />
|
||||
<span className="font-display text-[13px] text-white">Back to website</span>
|
||||
</div>
|
||||
</Container>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Container>
|
||||
<header className="flex items-center justify-between border-b border-b-gray-800/20 py-4 md:gap-x-24">
|
||||
<div className="mr-6 flex lg:hidden">
|
||||
<MobileNavigation navigation={navigation} />
|
||||
</div>
|
||||
<Link to="/docs" className="group flex shrink-0 items-end gap-x-1">
|
||||
<img
|
||||
alt="JetKVM for GitHub Actions"
|
||||
src="/logo-blue.png"
|
||||
className="block h-[24px] shrink-0 fill-slate-700"
|
||||
/>
|
||||
<span className="-translate-y-[1px] transform font-display text-xs font-semibold text-slate-600 ">
|
||||
Docs
|
||||
</span>
|
||||
</Link>
|
||||
{/* <div className="lg:w-full lg:max-w-2xl"> */}
|
||||
<div className="hidden">
|
||||
<Search />
|
||||
</div>
|
||||
<div className="hidden h-[36px] items-center gap-x-4 lg:flex">
|
||||
<LinkButton
|
||||
size="SM"
|
||||
theme="light"
|
||||
to="/discord"
|
||||
text="Join Discord"
|
||||
LeadingIcon={DiscordIcon}
|
||||
/>
|
||||
<LinkButton
|
||||
size="SM"
|
||||
theme="light"
|
||||
to="https://app.jetkvm.com"
|
||||
text="Cloud Dashboard"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
app/components/landingpage/DocsSidebar.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import clsx from "clsx";
|
||||
import { Link, useLocation } from "@remix-run/react";
|
||||
|
||||
type NavigationProps = {
|
||||
navigation: Array<{
|
||||
title: string;
|
||||
links: Array<{
|
||||
href: string;
|
||||
title: string;
|
||||
}>;
|
||||
}>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DocsSidebar({ navigation, className }: NavigationProps) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<nav className={clsx("text-base lg:text-sm", className)}>
|
||||
<ul role="list" className="space-y-8">
|
||||
{navigation.map(section => (
|
||||
<li key={section.title}>
|
||||
<h2 className="select-none font-display font-bold text-black">
|
||||
{section.title}
|
||||
</h2>
|
||||
<div
|
||||
role="list"
|
||||
className="mt-2 space-y-2 border-l border-gray-800/20 transition-colors duration-500 hover:border-blue-700 lg:mt-2 lg:space-y-2"
|
||||
>
|
||||
{section.links.map(link => (
|
||||
<div key={link.href} className="relative">
|
||||
<Link
|
||||
prefetch="intent"
|
||||
to={link.href}
|
||||
className={clsx(
|
||||
"inline w-full select-none pl-3.5 font-display",
|
||||
link.href === location.pathname
|
||||
? "font-semibold text-blue-700"
|
||||
: "text-slate-700 transition hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
48
app/components/landingpage/Footer.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import Container from "~/components/Container";
|
||||
import type { LinkProps } from "@remix-run/react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import React from "react";
|
||||
import { DiscordIcon } from "~/components/Icons";
|
||||
import ExtLink from "~/components/ExtLink";
|
||||
|
||||
const FooterLink = ({
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to: LinkProps["to"];
|
||||
}) => (
|
||||
<Link
|
||||
to={to}
|
||||
className="block whitespace-nowrap font-display text-sm leading-none text-slate-600 hover:text-black"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-t-slate-800/20 bg-white">
|
||||
<Container>
|
||||
<div className="flex flex-col justify-between gap-x-16 gap-y-8 py-8 md:flex-row">
|
||||
<div className="flex w-full justify-between gap-y-4 md:gap-y-16 ">
|
||||
<div>
|
||||
<h4 className="text-lg font-bold text-blue-700">JetKVM</h4>
|
||||
<p className="text-base text-slate-600 md:text-sm">
|
||||
Control any computer remotely
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 grayscale">
|
||||
<ExtLink href="https://jetkvm.com/discord">
|
||||
<DiscordIcon className="h-8 w-8 shrink-0" />
|
||||
</ExtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-t-slate-300 py-4 text-xs leading-none text-slate-500 md:text-sm">
|
||||
© 2024 BuildJet, Inc. - All rights reserved.
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
259
app/components/landingpage/Hero.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import React from "react";
|
||||
import { YCombinatorIcon } from "~/components/Icons";
|
||||
import clsx from "clsx";
|
||||
import { formatters } from "~/utils";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import Container from "~/components/Container";
|
||||
import Card from "~/components/Card";
|
||||
|
||||
function Base({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="relative isolate h-full overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LargeCenter({
|
||||
headline,
|
||||
description,
|
||||
slogan,
|
||||
ctaText,
|
||||
ctaTo,
|
||||
videoSrc,
|
||||
}: {
|
||||
headline: string;
|
||||
description: string;
|
||||
slogan?: string;
|
||||
ctaText?: string;
|
||||
ctaTo?: string;
|
||||
videoSrc: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="z-10 mx-auto max-w-3xl py-20 sm:py-56 3xl:py-56">
|
||||
<Container className="text-center">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-white drop-shadow-lg sm:text-5xl md:text-6xl">
|
||||
{headline}
|
||||
</h1>
|
||||
<div>
|
||||
<h2 className="mt-6 text-xl font-medium text-white md:text-2xl">
|
||||
{description}
|
||||
</h2>
|
||||
<h3 className="text-xl text-slate-300 md:text-2xl">{slogan}</h3>
|
||||
</div>
|
||||
<div className="mt-10 flex items-center justify-center gap-x-8">
|
||||
<div className="hidden md:block">
|
||||
<LinkButton
|
||||
size="LG"
|
||||
theme="light"
|
||||
text={ctaText || "Get started"}
|
||||
to={ctaTo || "/dashboard"}
|
||||
className="!border-white/10 font-display !text-blue-700 !shadow-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="block md:hidden">
|
||||
<LinkButton
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="Get started"
|
||||
to="/dashboard"
|
||||
className="!border-white/10 font-display !text-blue-700 !shadow-none"
|
||||
/>
|
||||
</div>
|
||||
<YCombinatorIcon className="w-32 text-white" />
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LargeLeft({
|
||||
headline,
|
||||
description,
|
||||
slogan,
|
||||
illustration,
|
||||
}: {
|
||||
headline: string;
|
||||
description: string;
|
||||
slogan?: string;
|
||||
illustration: string;
|
||||
}) {
|
||||
return (
|
||||
<Container className="text-center">
|
||||
<div className="flex h-full w-full items-center justify-start gap-x-8">
|
||||
<div className="z-10 w-full max-w-xl grow text-left">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-white drop-shadow-lg sm:text-5xl md:text-6xl">
|
||||
{headline}
|
||||
</h1>
|
||||
<div>
|
||||
<h2 className="mt-6 text-xl font-medium text-white md:text-2xl">
|
||||
{description}
|
||||
</h2>
|
||||
<h3 className="text-xl text-slate-300 md:text-2xl">{slogan}</h3>
|
||||
</div>
|
||||
<div className="mt-10 flex items-center justify-start gap-x-8">
|
||||
<div className="hidden md:block">
|
||||
<LinkButton
|
||||
size="LG"
|
||||
theme="light"
|
||||
text="Get started"
|
||||
to="/dashboard"
|
||||
className="!border-white/10 font-display !text-blue-700 !shadow-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="block md:hidden">
|
||||
<LinkButton
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="Get started"
|
||||
to="/dashboard"
|
||||
className="!border-white/10 font-display !text-blue-700 !shadow-none"
|
||||
/>
|
||||
</div>
|
||||
<YCombinatorIcon className="w-32 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Card>
|
||||
<div className="overflow-hidden rounded-md p-1">
|
||||
<img src={illustration} alt="" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function Small({
|
||||
headline,
|
||||
description,
|
||||
children,
|
||||
align = "center",
|
||||
divider,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
headline: string;
|
||||
description?: string;
|
||||
align?: "left" | "center";
|
||||
divider?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"z-10 pb-12 pt-20 md:py-20",
|
||||
className,
|
||||
divider && "pb-[calc(64px-1px)]",
|
||||
align === "left" && "mr-auto text-left",
|
||||
align === "center" && "mx-auto max-w-3xl text-center",
|
||||
)}
|
||||
>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-black md:text-5xl">
|
||||
{headline}
|
||||
</h1>
|
||||
{divider && <div className="mt-4 block h-[1px] w-full bg-slate-800/20" />}
|
||||
{description && (
|
||||
<div className={clsx("max-w-xl", align === "center" ? "mx-auto" : "")}>
|
||||
<h2 className="mt-2 font-display text-lg text-slate-700">{description}</h2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children && <div className="mb-16">{children}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostHero({
|
||||
category,
|
||||
headline,
|
||||
description,
|
||||
children,
|
||||
align = "center",
|
||||
divider,
|
||||
date,
|
||||
authors,
|
||||
}: {
|
||||
category?: string;
|
||||
children?: React.ReactNode;
|
||||
headline: string;
|
||||
description?: string;
|
||||
align?: "left" | "center";
|
||||
divider?: boolean;
|
||||
date?: string;
|
||||
authors?: { name: string; title?: string }[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"z-10",
|
||||
align === "left" && "mr-auto text-left",
|
||||
align === "center" && "mx-auto max-w-3xl text-center",
|
||||
)}
|
||||
>
|
||||
<div className="mb-6 mt-4">
|
||||
<Link
|
||||
to="./"
|
||||
className="flex gap-x-2 text-white/70 transition-colors hover:text-white"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4" />
|
||||
<span className="font-display text-sm">Back to blog</span>
|
||||
</Link>
|
||||
</div>
|
||||
{category && (
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg tracking-wide text-slate-50">{category}</h2>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-5xl font-bold tracking-tight text-white">{headline}</h1>
|
||||
|
||||
{description && (
|
||||
<div>
|
||||
<h2 className="mt-2 text-2xl text-slate-50">{description}</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{divider && <div className="mt-4 block h-[1px] w-full bg-white" />}
|
||||
|
||||
<div className="mb-6 mt-4">
|
||||
<div className="flex items-start gap-x-8">
|
||||
{date && (
|
||||
<div>
|
||||
<div className="font-display text-sm font-medium text-white">
|
||||
{formatters.date(new Date(date))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authors?.map(author => (
|
||||
<div key={author.name}>
|
||||
<div className="space-y-1 font-display text-sm text-white">
|
||||
<div className="font-display text-sm font-medium">{author.name}</div>
|
||||
{author.title && (
|
||||
<div className="text-xs text-slate-100">{author.title}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children && <div className="mb-16">{children}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Base, {
|
||||
LargeCenter,
|
||||
LargeLeft,
|
||||
Small,
|
||||
PostHero,
|
||||
});
|
||||
78
app/components/landingpage/LandingNavbar.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { Link, NavLink as RemixNavLink } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import ExtLink from "~/components/ExtLink";
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
|
||||
type LinkProps = {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type ButtonProps = {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type NavLinkProps = LinkProps | ButtonProps;
|
||||
|
||||
const NavLink = (props: NavLinkProps) => {
|
||||
const className =
|
||||
"font-display shrink-0 whitespace-nowrap rounded-md first:pl-0 py-2 px-3 text-sm text-slate-700 hover:text-blue-700/100 active:text-blue-900/100";
|
||||
|
||||
if ("to" in props) {
|
||||
const { to, children } = props;
|
||||
if (to.startsWith("http")) {
|
||||
return (
|
||||
<ExtLink href={to} className={className}>
|
||||
{children}
|
||||
</ExtLink>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<RemixNavLink
|
||||
to={to}
|
||||
prefetch="intent"
|
||||
className={({ isActive }) => clsx(className, isActive ? "" : "")}
|
||||
>
|
||||
{children}
|
||||
</RemixNavLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { onClick, children } = props;
|
||||
return (
|
||||
<button onClick={onClick} className={className}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default function LandingNavbar() {
|
||||
return (
|
||||
<div className="w-full flex-col items-center justify-between gap-x-6 space-y-4 pb-0 pt-6 xs:flex xs:flex-row xs:space-y-0 xs:border-b-0">
|
||||
<div>
|
||||
<Link to="/">
|
||||
<img src="/logo-blue.png" className="h-[24px] shrink-0" alt="" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<NavLink to="/docs">Documentation</NavLink>
|
||||
<NavLink to="/contact">Contact us</NavLink>
|
||||
{/*<div className="hidden md:block">*/}
|
||||
{/* <NavLink to="/contact">GitHub</NavLink>*/}
|
||||
{/*</div>*/}
|
||||
{/*<LinkButton*/}
|
||||
{/* size="SM"*/}
|
||||
{/* theme="light"*/}
|
||||
{/* to="https://app.jetkvm.com"*/}
|
||||
{/* text="Cloud Dashboard"*/}
|
||||
{/*/>*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
app/components/landingpage/MobileNavigation.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Dialog } from "@headlessui/react";
|
||||
import { DocsSidebar } from "~/components/landingpage/DocsSidebar";
|
||||
import { Link, useLocation } from "@remix-run/react";
|
||||
import { Button } from "../Button";
|
||||
|
||||
function MenuIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M5 5l14 14M19 5l-14 14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileNavigationProps = {
|
||||
navigation: any;
|
||||
};
|
||||
|
||||
export function MobileNavigation({ navigation }: MobileNavigationProps) {
|
||||
let location = useLocation();
|
||||
let [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname) setIsOpen(false);
|
||||
}, [location.key]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
onClick={() => setIsOpen(true)}
|
||||
TrailingIcon={MenuIcon}
|
||||
/>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={setIsOpen}
|
||||
className="fixed inset-0 z-50 flex items-start overflow-y-auto bg-slate-900/50 pr-10 backdrop-blur lg:hidden"
|
||||
aria-label="Navigation"
|
||||
>
|
||||
<Dialog.Panel className="min-h-full w-full max-w-xs bg-white px-4 pb-12 pt-5 sm:px-6">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<CloseIcon className="h-6 w-6 stroke-slate-500" />
|
||||
</button>
|
||||
<Link to="/docs" className="ml-6" aria-label="Home page">
|
||||
<img alt="JetKVM" src="/logo-blue.png" className="h-6 " />
|
||||
</Link>
|
||||
</div>
|
||||
<DocsSidebar navigation={navigation} className="mt-5 px-1" />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
app/components/landingpage/Prose.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type ProseProps = {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
textSize?: "sm" | "base" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
|
||||
};
|
||||
|
||||
export function Prose({ className, textSize = "base", children, ...props }: ProseProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
{
|
||||
"prose-sm": textSize === "sm",
|
||||
"prose-base": textSize === "base",
|
||||
"prose-lg": textSize === "lg",
|
||||
"prose-xl": textSize === "xl",
|
||||
"prose-2xl": textSize === "2xl",
|
||||
"prose-3xl": textSize === "3xl",
|
||||
},
|
||||
"prose prose-slate max-w-3xl",
|
||||
|
||||
// headings
|
||||
"prose-headings:scroll-mt-28 prose-headings:font-display prose-headings:font-semibold lg:prose-headings:scroll-mt-[8.5rem]",
|
||||
|
||||
"prose-h1:fond-bold prose-h2:font-bold",
|
||||
|
||||
// lead
|
||||
"prose-p:text-black/95",
|
||||
|
||||
//table
|
||||
"prose-table:my-0 prose-table:max-w-3xl prose-table:text-[15px] prose-table:text-slate-800 prose-thead:border-slate-800/10 prose-tr:border-slate-800/10 prose-td:py-3",
|
||||
|
||||
// links
|
||||
"prose-a:font-medium prose-a:text-blue-700",
|
||||
|
||||
// link underline
|
||||
"prose-a:no-underline",
|
||||
|
||||
// pre don't do any changes
|
||||
// "prose-pre:rounded-xl prose-pre:bg-slate-100 prose-pre:shadow-none dark:prose-pre:bg-slate-800/60 dark:prose-pre:shadow-none dark:prose-pre:ring-1 dark:prose-pre:ring-slate-300/10",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
app/components/landingpage/Search.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import * as DocSearch from "@docsearch/react";
|
||||
import { Link, useNavigate } from "@remix-run/react";
|
||||
import Card from "~/components/Card";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/16/solid";
|
||||
import { Button } from "../Button";
|
||||
|
||||
type HitProps = {
|
||||
hit: { url: string };
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const algoliaOptions = {
|
||||
appId: "5I4EPST2WK",
|
||||
apiKey: "4f955170e0f326372dfea6b36f3f8aa7",
|
||||
indexName: "prod_buildjet_gha",
|
||||
};
|
||||
|
||||
function Hit({ hit, children }: HitProps) {
|
||||
return <Link to={hit.url}>{children}</Link>;
|
||||
}
|
||||
|
||||
export interface UseDocSearchKeyboardEventsProps {
|
||||
isOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onInput?: (event: KeyboardEvent) => void;
|
||||
searchButtonRef?: React.RefObject<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
function isEditingContent(event: KeyboardEvent): boolean {
|
||||
const element = event.target as HTMLElement;
|
||||
const tagName = element.tagName;
|
||||
|
||||
return (
|
||||
element.isContentEditable ||
|
||||
tagName === "INPUT" ||
|
||||
tagName === "SELECT" ||
|
||||
tagName === "TEXTAREA"
|
||||
);
|
||||
}
|
||||
|
||||
export function useDocSearchKeyboardEvents({
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onInput,
|
||||
searchButtonRef,
|
||||
}: UseDocSearchKeyboardEventsProps) {
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
function open() {
|
||||
// We check that no other DocSearch modal is showing before opening
|
||||
// another one.
|
||||
if (!document.body.classList.contains("DocSearch--active")) {
|
||||
onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(event.keyCode === 27 && isOpen) ||
|
||||
// The `Cmd+K` shortcut both opens and closes the modal.
|
||||
// We need to check for `event.key` because it can be `undefined` with
|
||||
// Chrome's autofill feature.
|
||||
// See https://github.com/paperjs/paper.js/issues/1398
|
||||
(event.key?.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) ||
|
||||
// The `/` shortcut opens but doesn't close the modal because it's
|
||||
// a character.
|
||||
(!isEditingContent(event) && event.key === "/" && !isOpen)
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isOpen) {
|
||||
onClose();
|
||||
} else if (!document.body.classList.contains("DocSearch--active")) {
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
searchButtonRef &&
|
||||
searchButtonRef.current === document.activeElement &&
|
||||
onInput
|
||||
) {
|
||||
if (/[a-zA-Z0-9]/.test(String.fromCharCode(event.keyCode))) {
|
||||
onInput(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [isOpen, onOpen, onClose, onInput, searchButtonRef]);
|
||||
}
|
||||
|
||||
export function Search() {
|
||||
let [isOpen, setIsOpen] = useState(false);
|
||||
let [modifierKey, setModifierKey] = useState();
|
||||
const onOpen = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
}, [setIsOpen]);
|
||||
const navigate = useNavigate();
|
||||
const onClose = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, [setIsOpen]);
|
||||
|
||||
useDocSearchKeyboardEvents({ isOpen, onOpen, onClose });
|
||||
|
||||
useEffect(() => {
|
||||
setModifierKey(
|
||||
// @ts-ignore
|
||||
!/(Mac|iPhone|iPod|iPad)/i.test(navigator?.platform) ? "Ctrl " : "⌘",
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="block w-full lg:hidden">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
}}
|
||||
TrailingIcon={MagnifyingGlassIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="group hidden w-full cursor-pointer py-2 pl-4 pr-3.5 text-sm transition-colors hover:bg-blue-50/10 lg:block">
|
||||
<div
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
}}
|
||||
className="flex h-full items-center justify-center"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4 flex-none fill-slate-400 group-hover:fill-slate-400" />
|
||||
<span className="ml-2 font-display text-sm text-slate-600">Search...</span>
|
||||
{modifierKey && (
|
||||
<kbd className="ml-auto block font-medium text-slate-600">
|
||||
<kbd className="font-display">{modifierKey}</kbd>
|
||||
<kbd className="font-display">K</kbd>
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<DocSearch.DocSearchModal
|
||||
appId={algoliaOptions.appId}
|
||||
apiKey={algoliaOptions.apiKey}
|
||||
indexName={algoliaOptions.indexName}
|
||||
initialScrollY={window.scrollY}
|
||||
onClose={onClose}
|
||||
hitComponent={Hit}
|
||||
navigator={{
|
||||
navigate({ itemUrl }) {
|
||||
navigate(itemUrl);
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
123
app/components/landingpage/TableOfContents.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { cx } from "~/cva.config";
|
||||
|
||||
type TableOfContentsType = {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
children: Omit<TableOfContentsType, "children">[];
|
||||
};
|
||||
|
||||
function useTableOfContents(tableOfContents: TableOfContentsType[]) {
|
||||
let [currentSection, setCurrentSection] = useState(tableOfContents[0]?.id);
|
||||
|
||||
let getHeadings = useCallback((tableOfContents: TableOfContentsType[]) => {
|
||||
return tableOfContents
|
||||
.flatMap((node: TableOfContentsType) => [
|
||||
node.id,
|
||||
...node.children.map(child => child.id),
|
||||
])
|
||||
.map((id: string) => {
|
||||
let el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
|
||||
let style = window.getComputedStyle(el);
|
||||
let scrollMt = parseFloat(style.scrollMarginTop);
|
||||
|
||||
let top = window.scrollY + el.getBoundingClientRect().top - scrollMt;
|
||||
return { id, top };
|
||||
})
|
||||
.filter(x => x) as { id: string; top: number }[];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tableOfContents.length === 0) return;
|
||||
let headings = getHeadings(tableOfContents);
|
||||
|
||||
function onScroll() {
|
||||
let top = window.scrollY;
|
||||
let current = headings[0].id;
|
||||
for (let heading of headings) {
|
||||
if (top >= heading.top) {
|
||||
current = heading?.id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setCurrentSection(current);
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [getHeadings, tableOfContents]);
|
||||
|
||||
return currentSection;
|
||||
}
|
||||
|
||||
export default function TableOfContents({
|
||||
tableOfContents,
|
||||
}: {
|
||||
tableOfContents: TableOfContentsType[];
|
||||
}) {
|
||||
let currentSection = useTableOfContents(tableOfContents);
|
||||
const isActive = (section: { id: string; children?: Array<{ id: string }> }) => {
|
||||
if (section.id === currentSection) return true;
|
||||
if (!section.children) return false;
|
||||
|
||||
return section.children.findIndex(isActive) > -1;
|
||||
};
|
||||
|
||||
return (
|
||||
<nav aria-labelledby="on-this-page-title" className="w-56">
|
||||
{tableOfContents.length > 0 && (
|
||||
<>
|
||||
<h2
|
||||
id="on-this-page-title"
|
||||
className="font-display text-sm font-semibold text-black"
|
||||
>
|
||||
On this page
|
||||
</h2>
|
||||
<ol role="list" className="mt-4 space-y-3 text-sm">
|
||||
{tableOfContents.map(section => (
|
||||
<li key={section.id}>
|
||||
<h3>
|
||||
<Link
|
||||
to={`#${section.id}`}
|
||||
className={cx(
|
||||
isActive(section) ? "!text-blue-700" : "!text-slate-700 transition",
|
||||
)}
|
||||
>
|
||||
{section.title}
|
||||
</Link>
|
||||
</h3>
|
||||
{section.children.length > 0 && (
|
||||
<ol role="list" className="mt-2 space-y-2 pl-5 text-slate-700">
|
||||
{section.children.map(subSection => (
|
||||
<li key={subSection.id}>
|
||||
<Link
|
||||
to={`#${subSection.id}`}
|
||||
className={cx(
|
||||
"truncate font-display text-sm",
|
||||
isActive(subSection)
|
||||
? "text-blue-700"
|
||||
: "hover:text-slate-700",
|
||||
)}
|
||||
>
|
||||
{subSection.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
8
app/cva.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "cva";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const { cva, cx, compose } = defineConfig({
|
||||
hooks: {
|
||||
onComplete: className => twMerge(className),
|
||||
},
|
||||
});
|
||||
18
app/entry.client.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* By default, Remix will handle hydrating your app on the client for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/file-conventions/entry.client
|
||||
*/
|
||||
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
122
app/entry.server.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* By default, Remix will handle generating the HTTP Response for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/file-conventions/entry.server
|
||||
*/
|
||||
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { AppLoadContext, EntryContext } from "@remix-run/node";
|
||||
import { createReadableStreamFromReadable } from "@remix-run/node";
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import isbot from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server"; // This starts all the scheduled jobs we need, like billing and fraud
|
||||
|
||||
// This starts all the scheduled jobs we need, like billing and fraud
|
||||
|
||||
const ABORT_DELAY = 5_000;
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
loadContext: AppLoadContext,
|
||||
) {
|
||||
return isbot(request.headers.get("user-agent"))
|
||||
? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
|
||||
: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
|
||||
}
|
||||
|
||||
function handleBotRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
|
||||
{
|
||||
onAllReady() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrowserRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
|
||||
{
|
||||
onShellReady() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
responseHeaders.set("X-Frame-Options", "DENY");
|
||||
// TODO: https://github.com/sergiodxa/remix-utils?tab=readme-ov-file#cors
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
140
app/root.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import type { LinksFunction } from "@remix-run/node";
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useRouteError,
|
||||
} from "@remix-run/react";
|
||||
import fonts from "@fontsource-variable/source-code-pro/wght.css?url";
|
||||
import tailwind from "~/styles/tailwind.css?url";
|
||||
import docsearch from "~/styles/docsearch.css?url";
|
||||
import NotFoundPage from "~/components/NotFoundPage";
|
||||
import Card from "~/components/Card";
|
||||
import EmptyCard from "~/components/EmptyCard";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import "decimal.js-light";
|
||||
|
||||
try {
|
||||
Object.defineProperty(BigInt.prototype, "toJSON", {
|
||||
get() {
|
||||
"use strict";
|
||||
return () => String(this);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Unable to define toJSON on BigInt.prototype");
|
||||
}
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: tailwind },
|
||||
{ rel: "stylesheet", href: docsearch },
|
||||
{ rel: "stylesheet", href: fonts },
|
||||
{ rel: "stylesheet", href: "/fonts/fonts.css" },
|
||||
];
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/CircularXXWeb-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/CircularXXWeb-Bold.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/CircularXXWeb-Book.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/CircularXXWeb-Medium.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<Meta />
|
||||
<Links />
|
||||
<script
|
||||
defer
|
||||
data-domain="jetkvm.com"
|
||||
data-api="https://thinkbeforeafter.cloudindex.workers.dev/api/occasion"
|
||||
src="https://thinkbeforeafter.cloudindex.workers.dev/js/playwright.js"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
<Outlet />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<a rel="me" className="hidden" href="https://mastodon.social/@jetkvm">
|
||||
Mastodon
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
|
||||
// @ts-ignore
|
||||
const errorMessage = error.data?.error?.message || error?.message;
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (error.status === 404) return <NotFoundPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<title>JetKVM</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="w-full max-w-2xl">
|
||||
<EmptyCard
|
||||
IconElm={ExclamationTriangleIcon}
|
||||
headline="Oh no!"
|
||||
description="Something went wrong. Please try again later or contact support"
|
||||
BtnElm={
|
||||
errorMessage && (
|
||||
<Card>
|
||||
<div className="flex items-center font-mono">
|
||||
<div className="flex p-2 text-black">
|
||||
<span className="text-sm">{errorMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
93
app/routes/_landingpage._index.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import Hero from "~/components/landingpage/Hero";
|
||||
import { MetaFunction } from "@remix-run/node";
|
||||
|
||||
import { openGraphTags } from "~/utils";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Container from "~/components/Container";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { DiscordIcon, YCombinatorIcon } from "~/components/Icons";
|
||||
import KickstarterIcon from "~/assets/kickstarter-icon.svg";
|
||||
|
||||
export const meta: MetaFunction = ({ data }) => {
|
||||
return [
|
||||
...openGraphTags(
|
||||
"JetKVM - Control any computer remotely",
|
||||
"JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively.",
|
||||
"JetKVM - Control any computer remotely",
|
||||
"Next generation KVM over IP"
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
export default function IndexRoute() {
|
||||
const [contentLoaded, setContentLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.src = "/jetkvm-device-still3.png";
|
||||
img.onload = () => setContentLoaded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-[10000px] max-h-[calc(100vh-60px)] min-h-[768px]">
|
||||
<Hero>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="z-10 mx-auto max-w-2xl ">
|
||||
{contentLoaded && (
|
||||
<Container className="space-y-1 text-center">
|
||||
<div
|
||||
className="animate-fadeIn space-y-4 opacity-0"
|
||||
style={{ animationDelay: "1000ms" }}
|
||||
>
|
||||
<h1 className="text-6xl font-bold tracking-tight text-black sm:text-5xl md:text-6xl">
|
||||
Control any computer remotely
|
||||
</h1>
|
||||
<h2 className="text-xl font-medium text-slate-700 md:text-2xl">
|
||||
Next generation open-source KVM over IP for $69
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="-ml-6 flex animate-fadeInScaleFloat justify-center opacity-0 md:-ml-0 md:-mt-6"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
>
|
||||
<img
|
||||
src="/jetkvm-device-still3.png"
|
||||
alt="JetKVM device"
|
||||
className="w-[80%]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="-mt-8 flex animate-fadeIn flex-col items-center justify-center gap-x-8 gap-y-8 opacity-0"
|
||||
style={{ animationDelay: "1500ms" }}
|
||||
>
|
||||
<div className="flex flex-wrap justify-center gap-x-6 gap-y-6">
|
||||
<div className="">
|
||||
<LinkButton
|
||||
size="XL"
|
||||
theme="light"
|
||||
className="plausible-event-name=Go+To+Kickstarter"
|
||||
text="Go to Kickstarter"
|
||||
LeadingIcon={({ className }) => (
|
||||
<img className={className} src={KickstarterIcon} />
|
||||
)}
|
||||
to="https://www.kickstarter.com/projects/jetkvm/jetkvm?ref=5sxcqi"
|
||||
/>
|
||||
</div>
|
||||
<LinkButton
|
||||
size="XL"
|
||||
theme="blank"
|
||||
LeadingIcon={DiscordIcon}
|
||||
text="Join our Discord"
|
||||
to="https://jetkvm.com/discord"
|
||||
/>
|
||||
</div>
|
||||
<YCombinatorIcon className="md:block hidden w-32 text-black" />
|
||||
</div>
|
||||
</Container>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Hero>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
app/routes/_landingpage.contact.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import Hero from "~/components/landingpage/Hero";
|
||||
import Card from "~/components/Card";
|
||||
import Container from "~/components/Container";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { openGraphTags } from "../utils";
|
||||
import { MetaFunction } from "@remix-run/node";
|
||||
import { DiscordIcon } from "../components/Icons";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
...openGraphTags(
|
||||
"JetKVM - Contact Us",
|
||||
"Next generation KVM over IP",
|
||||
"JetKVM - Contact Us",
|
||||
"Next generation KVM over IP",
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
export default function ContactUsRoute() {
|
||||
return (
|
||||
<div className="mb-8 h-full">
|
||||
<Container className="md:!max-w-6xl">
|
||||
<Hero.Small
|
||||
headline="Contact Us"
|
||||
description="For assistance with server selections, pricing, or any support queries, our dedicated team is here for you."
|
||||
/>
|
||||
<div className="mx-auto grid items-start gap-x-8 gap-y-8 md:grid-cols-2 ">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-2xl font-bold">Contact</h3>
|
||||
<p className="mt-1 text-slate-600">
|
||||
For general inquiries, please contact us at the email address below.
|
||||
</p>
|
||||
<p className="mt-4 text-blue-700">
|
||||
<a href="mailto:contact@jetkvm.com">contact@jetkvm.com</a>
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<h3 className="text-2xl font-bold">Shipping</h3>
|
||||
<p className="mt-1 text-slate-600">For questions about shipping, please contact us at the email address below.</p>
|
||||
<p className="mt-4 text-blue-700">
|
||||
<a href="mailto:shipping@jetkvm.com">shipping@jetkvm.com</a>
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-2xl font-bold">Sales & Distributors</h3>
|
||||
<p className="mt-1 text-slate-600">
|
||||
Interested in large quantities, data-center KVM form factors or re-selling. Let's discuss how we can meet
|
||||
your specific requirements.
|
||||
</p>
|
||||
<p className="mt-4 text-blue-700">
|
||||
<a href="mailto:sales@jetkvm.com">sales@jetkvm.com</a>
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<h3 className="text-2xl font-bold">Press</h3>
|
||||
<p className="mt-1 text-slate-600">
|
||||
For press inquiries, please contact us at the email address below.
|
||||
</p>
|
||||
<p className="mt-4 text-blue-700">
|
||||
<a href="mailto:press@jetkvm.com">press@jetkvm.com</a>
|
||||
</p>
|
||||
<hr className="mt-4 block border-slate-800/20" />
|
||||
<LinkButton
|
||||
to="https://drive.google.com/drive/folders/1KGj4tcjIwXfV0Phuos-WlSZW5At8JnUD?usp=drive_link"
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="Download Press Kit"
|
||||
className="mt-4"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
app/routes/_landingpage.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import LandingNavbar from "~/components/landingpage/LandingNavbar";
|
||||
import Container from "~/components/Container";
|
||||
|
||||
import { HeadersFunction } from "@remix-run/node";
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import Footer from "~/components/landingpage/Footer";
|
||||
|
||||
export const headers: HeadersFunction = () => {
|
||||
/*
|
||||
This is also the headers for the docs route.
|
||||
We have to re-export it in the docs route, because we override the _landingpage layout in the docs.
|
||||
Therefore, we need to re-declare the headers in the docs route.
|
||||
|
||||
So, if you change the headers here, make sure you consider the docs route as well.
|
||||
|
||||
Note: We can cache because all the content is static, and the things that are dynamic are handled by the client.
|
||||
Like the user's login status - see useIsLoggedIn hook.
|
||||
*/
|
||||
|
||||
/*
|
||||
This implementation leverages the stale-while-revalidate (SWR) strategy to manage caching behavior, aiming to serve cached content for 120 seconds.
|
||||
Beyond this duration, the intention is to fetch fresh data from the origin server in the background, while continuing to deliver the cached version to the user until the refresh completes.
|
||||
However, Cloudflare, which powers the CDN for DigitalOcean Apps, has a slightly different approach to handling SWR, diverging from the ideal behavior we might expect.
|
||||
|
||||
Cloudflare's take on SWR unfolds as follows:
|
||||
- After the cached content officially expires (let's say at 00:00:00), and a new request comes in (imagine at 00:00:01), Cloudflare attempts to retrieve fresh data from the origin server.
|
||||
The twist here is that during this initial fetch, Cloudflare does not serve the stale cached content. This means the user experiences a wait time, deviating from the anticipated SWR behavior.
|
||||
|
||||
- For any simultaneous requests (e.g., at 00:00:01) or those made while the origin fetch is ongoing, Cloudflare delivers the "stale" content from the cache.
|
||||
|
||||
Essentially, the very first request following cache expiration incurs a delay as the system fetches new data. Meanwhile, subsequent requests during this interval benefit from the cached content, bypassing the wait time.
|
||||
Although this method doesn't fully sidestep the delay for the initial post-expiration request, it offers a middle ground, sparing all users from the latency of cache refreshment.
|
||||
|
||||
For further insights:
|
||||
- https://stackoverflow.com/questions/48124415/does-cloudflare-support-stale-while-revalidate
|
||||
*/
|
||||
return {
|
||||
"Cache-Control": `public, s-max-age=120, stale-while-revalidate=86400`,
|
||||
};
|
||||
};
|
||||
|
||||
export default function LandingRootRoute() {
|
||||
return (
|
||||
<div className="grid h-full ">
|
||||
<Container>
|
||||
<LandingNavbar />
|
||||
</Container>
|
||||
<div className="h-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
app/routes/_landingpage_.docs.$.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import { json, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||
import { getLocalContent, Section } from "~/services/file.server";
|
||||
import { NotFoundResponse } from "~/services/errors.server";
|
||||
import { parseMdx } from "~/services/mdx-bundler.server";
|
||||
import { useLoaderData, useLocation, useRouteLoaderData } from "@remix-run/react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { getMDXComponent } from "mdx-bundler/client/index.js";
|
||||
import Card, { GridCard } from "~/components/Card";
|
||||
import Alert from "~/components/Alert";
|
||||
import ExclamationTriangleIcon from "@heroicons/react/24/outline/ExclamationTriangleIcon";
|
||||
import { Prose } from "~/components/landingpage/Prose";
|
||||
import TableOfContents from "~/components/landingpage/TableOfContents";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { DiscordIcon } from "~/components/Icons";
|
||||
import ExtLink from "../components/ExtLink";
|
||||
import { openGraphTags } from "../utils";
|
||||
|
||||
interface Heading {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
type TableOfContentsType = {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
children: TableOfContentsType[];
|
||||
};
|
||||
|
||||
function extractHeadings(content: string): Heading[] {
|
||||
const headingRegex = /^(#{2,4})\s+(.+)$/gm;
|
||||
const flatHeadings: Heading[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = headingRegex.exec(content)) !== null) {
|
||||
const level = match[1].length;
|
||||
const title = match[2];
|
||||
const id = title
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^\w-]+/g, "");
|
||||
flatHeadings.push({ id, title, level });
|
||||
}
|
||||
|
||||
return flatHeadings;
|
||||
}
|
||||
|
||||
function transformToHierarchy(headings: Heading[]): TableOfContentsType[] {
|
||||
const tableOfContents: TableOfContentsType[] = [];
|
||||
let currentParent: TableOfContentsType | null = null;
|
||||
|
||||
headings.forEach(heading => {
|
||||
if (heading.level === 2) {
|
||||
currentParent = {
|
||||
id: heading.id,
|
||||
title: heading.title,
|
||||
level: heading.level,
|
||||
children: [],
|
||||
};
|
||||
tableOfContents.push(currentParent);
|
||||
} else if (heading.level === 3 || heading.level === 4) {
|
||||
if (currentParent) {
|
||||
currentParent.children.push({
|
||||
id: heading.id,
|
||||
title: heading.title,
|
||||
level: heading.level,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return tableOfContents;
|
||||
}
|
||||
|
||||
let cachedPosts = new Map<string, any>();
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
let splat = params["*"];
|
||||
const cacheKey = splat || "index";
|
||||
|
||||
// Only cache files in production
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
// Check if the post is cached
|
||||
let cachedPost = cachedPosts.get(cacheKey);
|
||||
|
||||
let parsedPost;
|
||||
|
||||
// Always get the raw file in non-production environments
|
||||
// In production, we can use the cache as the files are not expected to change at runtime
|
||||
if (cachedPost && isProduction) {
|
||||
console.log("Using cached post", cacheKey);
|
||||
return json({
|
||||
post: cachedPost.mdx as Awaited<ReturnType<typeof parseMdx>>,
|
||||
tableOfContents: cachedPost.tableOfContents as TableOfContentsType[],
|
||||
});
|
||||
}
|
||||
|
||||
const file = await getLocalContent(`docs/${cacheKey}.mdx`);
|
||||
if (!file) throw NotFoundResponse();
|
||||
if (!file.content) throw NotFoundResponse();
|
||||
const mdx = await parseMdx(file.content);
|
||||
const headings = extractHeadings(file.content);
|
||||
const tableOfContents = transformToHierarchy(headings);
|
||||
|
||||
parsedPost = { mdx, tableOfContents };
|
||||
|
||||
cachedPosts.set(cacheKey, parsedPost);
|
||||
|
||||
return json({
|
||||
post: parsedPost.mdx as Awaited<ReturnType<typeof parseMdx>>,
|
||||
tableOfContents: parsedPost.tableOfContents as TableOfContentsType[],
|
||||
});
|
||||
};
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||
const post = data?.post;
|
||||
if (!post) return [];
|
||||
const { frontmatter } = post;
|
||||
const title = frontmatter.title ? "JetKVM Docs - " + frontmatter.title : "JetKVM Docs";
|
||||
const description = frontmatter?.description;
|
||||
|
||||
return [
|
||||
...openGraphTags(
|
||||
title,
|
||||
description || "Next generation KVM over IP",
|
||||
title,
|
||||
"Next generation KVM over IP",
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
export default function DocsRoute() {
|
||||
let { post, tableOfContents } = useLoaderData<typeof loader>();
|
||||
const { navigation } = useRouteLoaderData("routes/_landingpage_.docs") as {
|
||||
navigation: Section[];
|
||||
};
|
||||
const { code, frontmatter } = post;
|
||||
|
||||
const title = frontmatter.title;
|
||||
const location = useLocation();
|
||||
let section = navigation?.find(section =>
|
||||
section.links.find(link => link.href === location.pathname),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const Component = useMemo(() => getMDXComponent(code), [code]);
|
||||
return (
|
||||
<>
|
||||
<div className="min-w-0 max-w-2xl flex-auto border-l border-r border-dashed border-l-gray-800/20 border-r-gray-800/20 bg-white px-4 pb-16 pt-8 lg:max-w-none lg:border-l-0 lg:pl-8 lg:pr-0 xl:px-12">
|
||||
<article>
|
||||
{(title || section) && (
|
||||
<header className="mb-4 space-y-1">
|
||||
{section && (
|
||||
<p className="font-display text-sm font-medium text-blue-700">
|
||||
{section.title}
|
||||
</p>
|
||||
)}
|
||||
{title && (
|
||||
<h1 className="font-display text-3xl font-medium tracking-tight text-black">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
<Prose textSize="sm" className="!prose-p:text-slate-100">
|
||||
{code && (
|
||||
<Component
|
||||
components={{
|
||||
Card,
|
||||
Alert,
|
||||
ExclamationTriangleIcon,
|
||||
LinkButton,
|
||||
DiscordIcon,
|
||||
GridCard,
|
||||
ExtLink,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Prose>
|
||||
</article>
|
||||
</div>
|
||||
<div className="hidden pl-8 xl:sticky xl:top-[6.5rem] xl:-mr-6 xl:block xl:h-[calc(100vh-2rem)] xl:flex-none xl:overflow-y-auto xl:py-8 xl:pr-6">
|
||||
<TableOfContents tableOfContents={tableOfContents} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
app/routes/_landingpage_.docs._index.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
/*
|
||||
The $splat route _landingpage_.docs.$.tsx doesn't get called on Index,
|
||||
so we simply re-export the loader and RouteComponent from that file.
|
||||
*/
|
||||
|
||||
import RouteComponent from "./_landingpage_.docs.$";
|
||||
|
||||
export { loader } from "./_landingpage_.docs.$";
|
||||
export { meta } from "./_landingpage_.docs.$";
|
||||
export default RouteComponent;
|
||||
74
app/routes/_landingpage_.docs.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { json, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { generateNavigationObject, Section } from "~/services/file.server";
|
||||
import { Outlet, useLoaderData } from "@remix-run/react";
|
||||
import { DocsNavbar } from "~/components/landingpage/DocsNavbar";
|
||||
import Container from "~/components/Container";
|
||||
import { DocsSidebar } from "~/components/landingpage/DocsSidebar";
|
||||
import Footer from "~/components/landingpage/Footer";
|
||||
import { openGraphTags } from "../utils";
|
||||
|
||||
export { headers } from "~/routes/_landingpage";
|
||||
|
||||
let cachedNavigation: Section[] = [];
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
if (cachedNavigation.length > 0 && process.env.NODE_ENV === "production") {
|
||||
console.log("Returning cached navigation");
|
||||
return json({ navigation: cachedNavigation });
|
||||
}
|
||||
|
||||
const navigation = await generateNavigationObject();
|
||||
// Preferred order for sections
|
||||
const preferredOrder = [
|
||||
"Getting Started",
|
||||
"Peripheral Devices",
|
||||
"Networking",
|
||||
"Video",
|
||||
"Advanced Usage",
|
||||
];
|
||||
|
||||
// Sorting function for sections
|
||||
const sortedNavigation = navigation.sort((a, b) => {
|
||||
const indexA = preferredOrder.indexOf(a.title);
|
||||
const indexB = preferredOrder.indexOf(b.title);
|
||||
|
||||
if (indexA > -1 && indexB > -1) {
|
||||
// Both titles are in the preferred order list
|
||||
return indexA - indexB;
|
||||
} else if (indexA > -1) {
|
||||
// Only title A is in the list
|
||||
return -1;
|
||||
} else if (indexB > -1) {
|
||||
// Only title B is in the list
|
||||
return 1;
|
||||
} else {
|
||||
// Neither title is in the list, sort alphabetically
|
||||
return a.title.localeCompare(b.title);
|
||||
}
|
||||
});
|
||||
|
||||
cachedNavigation = sortedNavigation;
|
||||
|
||||
return json({ navigation: sortedNavigation });
|
||||
};
|
||||
|
||||
export default function DocsRoute() {
|
||||
let { navigation } = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<div className="relative h-auto">
|
||||
<DocsNavbar navigation={navigation} isLoggedIn={false} />
|
||||
|
||||
<Container className="relative flex h-auto justify-center">
|
||||
<div className="hidden lg:relative lg:block lg:flex-none">
|
||||
<div className="absolute inset-y-0 right-0 w-[50vw]" />
|
||||
<div className="absolute bottom-0 right-0 top-16 hidden h-12 w-px bg-gradient-to-t from-slate-800" />
|
||||
<div className="absolute bottom-0 right-0 top-28 hidden w-px bg-slate-800" />
|
||||
<div className="sticky top-[105px] -ml-0.5 h-[calc(100vh-4.5rem)] overflow-y-auto overflow-x-hidden border-r border-dashed border-r-gray-800/20 pb-16 pl-0.5 pt-8">
|
||||
<DocsSidebar navigation={navigation} className="w-64 pr-8" />
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
</Container>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
app/routes/discord.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { LoaderFunctionArgs, redirect } from "@remix-run/node";
|
||||
|
||||
export function loader() {
|
||||
return redirect("https://discord.gg/Ky9v3tF7e5");
|
||||
}
|
||||
5
app/routes/faq.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { LoaderFunctionArgs, redirect } from "@remix-run/node";
|
||||
|
||||
export function loader() {
|
||||
return redirect("/docs/getting-started/faq");
|
||||
}
|
||||
5
app/routes/getting-started.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from "@remix-run/node";
|
||||
|
||||
export function loader() {
|
||||
return redirect("https://jetkvm.com/docs/getting-started/quick-start");
|
||||
}
|
||||
11
app/routes/kickstarter.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { LoaderFunctionArgs, redirect } from "@remix-run/node";
|
||||
|
||||
export function loader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams.toString();
|
||||
const kickstarterUrl = new URL("https://www.kickstarter.com/projects/jetkvm/jetkvm");
|
||||
if (searchParams) {
|
||||
kickstarterUrl.search = searchParams;
|
||||
}
|
||||
return redirect(kickstarterUrl.toString());
|
||||
}
|
||||
5
app/routes/ks-faq.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { LoaderFunctionArgs, redirect } from "@remix-run/node";
|
||||
|
||||
export function loader() {
|
||||
return redirect("/docs/getting-started/ks-faq");
|
||||
}
|
||||
139
app/routes/og/route.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { Resvg } from "@resvg/resvg-js";
|
||||
import satori from "satori";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { LoaderFunctionArgs } from "@remix-run/node";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const jsonDirectory = path.join(process.cwd());
|
||||
|
||||
// Read the json data file data.json
|
||||
const CircularBold = await fs.readFile(
|
||||
jsonDirectory + "/public/fonts/CircularXXWeb-Bold.woff",
|
||||
);
|
||||
const CircularMedium = await fs.readFile(
|
||||
jsonDirectory + "/public/fonts/CircularXXWeb-Medium.woff",
|
||||
);
|
||||
const CircularRegular = await fs.readFile(
|
||||
jsonDirectory + "/public/fonts/CircularXXWeb-Regular.woff",
|
||||
);
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const hasTitle = searchParams.has("title");
|
||||
const hasDescription = searchParams.has("description");
|
||||
|
||||
if (!hasTitle && !hasDescription) {
|
||||
console.log("No title, description or product provided");
|
||||
throw new Error("No title, description or product provided");
|
||||
}
|
||||
|
||||
const title = searchParams.get("title")?.slice(0, 100);
|
||||
const description = searchParams.get("description")?.slice(0, 100);
|
||||
|
||||
const element = (
|
||||
<div
|
||||
// @ts-ignore
|
||||
tw="p-24"
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
fontFamily: "Circular",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "blue",
|
||||
backgroundImage: `url("https://jetkvm.com/bg.png")`,
|
||||
backgroundSize: "100% 100%",
|
||||
gap: "64px",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "16px", display: "flex" }}>
|
||||
<img src="https://jetkvm.com/logo.png" height={64} alt="" />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
// @ts-ignore
|
||||
tw="text-7xl max-w-3xl"
|
||||
style={{
|
||||
display: "flex",
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
// @ts-ignore
|
||||
tw="text-4xl"
|
||||
style={{
|
||||
display: "flex",
|
||||
marginBottom: "32px",
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "4px", flexDirection: "column" }}>
|
||||
<div
|
||||
style={{
|
||||
borderBottom: "4px solid rgba(255, 255, 255, 0.5)",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const svg = await satori(element, {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
embedFont: true,
|
||||
fonts: [
|
||||
{
|
||||
name: "Circular",
|
||||
data: CircularRegular,
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
name: "Circular",
|
||||
data: CircularMedium,
|
||||
weight: 500,
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
name: "Circular",
|
||||
data: CircularBold,
|
||||
style: "normal",
|
||||
weight: 700,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const resvg = new Resvg(svg);
|
||||
const buffer = resvg.render().asPng();
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"content-type": "image/png",
|
||||
"cache-control":
|
||||
"public, immutable, no-transform, s-max-age=31536000, max-age=31536000",
|
||||
},
|
||||
});
|
||||
}
|
||||
7
app/routes/press.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { redirect } from "@remix-run/node";
|
||||
|
||||
export function loader() {
|
||||
return redirect(
|
||||
"https://drive.google.com/drive/folders/1KGj4tcjIwXfV0Phuos-WlSZW5At8JnUD?usp=drive_link",
|
||||
);
|
||||
}
|
||||
12
app/services/errors.server.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { json } from "@remix-run/node";
|
||||
|
||||
/**
|
||||
* This helper function helps us to return the accurate HTTP status,
|
||||
* 400 Bad Request, to the client.
|
||||
*/
|
||||
export const BadRequestResponse = (message: string) =>
|
||||
json({ error: { message } }, { status: 400 });
|
||||
export const NotFoundResponse = () =>
|
||||
new Response(null, { status: 404, statusText: "Not Found" });
|
||||
export const NotImplementedError = () =>
|
||||
json(null, { status: 404, statusText: "Not Implemented" });
|
||||
104
app/services/file.server.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import path, { join } from "path";
|
||||
|
||||
import * as fs from "fs/promises";
|
||||
import { readdir } from "fs/promises";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { NotFoundResponse } from "~/services/errors.server";
|
||||
import matter from "gray-matter";
|
||||
import { parseMdx } from "~/services/mdx-bundler.server";
|
||||
|
||||
export const contentPath = join(process.cwd(), "content");
|
||||
|
||||
export const getLocalContent = async (path: string) => {
|
||||
try {
|
||||
if (!existsSync(join(contentPath, path))) throw new Error("No file found");
|
||||
|
||||
const fullPath = join(contentPath, path);
|
||||
const data = readFileSync(fullPath, { encoding: "utf-8" });
|
||||
if (!data) throw new Error("No data found");
|
||||
return { content: data, slug: "/" + path.replace(".mdx", "") };
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
if (error.code?.includes("ENOENT")) {
|
||||
throw NotFoundResponse();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllBlogFiles = async () => {
|
||||
const sitePosts = await readdir(join(contentPath, "/blog"));
|
||||
const slugs = sitePosts.filter(path => path.endsWith(".mdx"));
|
||||
return await Promise.all(slugs.map(async slug => getLocalContent(`blog/${slug}`)));
|
||||
};
|
||||
|
||||
type Link = { title: string; href: string; order?: number };
|
||||
export type Section = { title: string; links: Link[] };
|
||||
|
||||
const convertToTitle = (str: string): string => {
|
||||
return str
|
||||
.replace(/-/g, " ")
|
||||
.replace(/and/g, "&")
|
||||
.replace(/\.md$/, "")
|
||||
.replace(/\.mdx$/, "")
|
||||
.replace(/\b\w/g, l => l.toUpperCase());
|
||||
};
|
||||
|
||||
const getLinkOrder = async (content: string): Promise<number> => {
|
||||
const frontmatter = matter(content).data;
|
||||
return frontmatter.order || Infinity; // Default to a high number if 'order' is not specified
|
||||
};
|
||||
|
||||
export const generateNavigationObject = async (): Promise<Section[]> => {
|
||||
const dir = join(contentPath, "docs");
|
||||
const navigation: Section[] = [];
|
||||
const items = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
if (item.isDirectory()) {
|
||||
const sectionDir = join(dir, item.name);
|
||||
const files = await fs.readdir(sectionDir, { withFileTypes: true });
|
||||
const links: Link[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.isFile() && file.name.endsWith(".mdx")) {
|
||||
const filePath = join(sectionDir, file.name);
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
|
||||
const parsedPost = await parseMdx(content);
|
||||
const { title } = parsedPost.frontmatter;
|
||||
|
||||
const order = await getLinkOrder(content);
|
||||
links.push({
|
||||
title: title || convertToTitle(file.name),
|
||||
href: `/${join("docs", item.name, path.parse(file.name).name).replace(
|
||||
/\\/g,
|
||||
"/",
|
||||
)}`,
|
||||
order,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort links by 'order', then alphabetically
|
||||
links.sort((a, b) => {
|
||||
// Providing a default value if 'order' is undefined
|
||||
const orderA = a.order !== undefined ? a.order : Infinity;
|
||||
const orderB = b.order !== undefined ? b.order : Infinity;
|
||||
|
||||
// First, compare by 'order', using the default value if necessary
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
|
||||
// If 'order' is the same or not specified, then sort alphabetically by title
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
navigation.push({ title: convertToTitle(item.name), links });
|
||||
}
|
||||
}
|
||||
|
||||
return navigation;
|
||||
};
|
||||
32
app/services/mdx-bundler.server.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { bundleMDX } from "mdx-bundler";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import rehypePrism from "rehype-prism-plus";
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
export async function parseMdx(mdx: string) {
|
||||
const { frontmatter, code } = await bundleMDX({
|
||||
source: mdx,
|
||||
mdxOptions(options) {
|
||||
options.rehypePlugins = [
|
||||
...(options.rehypePlugins ?? []),
|
||||
[rehypeSlug],
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
behavior: "wrap",
|
||||
properties: { className: ["anchor"] },
|
||||
},
|
||||
],
|
||||
[rehypePrism, { showLineNumbers: true }],
|
||||
remarkGfm,
|
||||
];
|
||||
return options;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
code,
|
||||
};
|
||||
}
|
||||
509
app/styles/docsearch.css
Normal file
@ -0,0 +1,509 @@
|
||||
/*! @docsearch/css 3.1.0 | MIT License | © Algolia, Inc. and contributors | https://docsearch.algolia.com */
|
||||
:root {
|
||||
--docsearch-primary-color: red;
|
||||
--docsearch-highlight-color: var(--docsearch-primary-color);
|
||||
--docsearch-muted-color: theme("colors.slate.500");
|
||||
--docsearch-emphasis-color: theme("colors.slate.900");
|
||||
--docsearch-logo-color: #5468ff;
|
||||
--docsearch-modal-width: 35rem;
|
||||
--docsearch-modal-height: 37.5rem;
|
||||
--docsearch-modal-background: theme("colors.white");
|
||||
--docsearch-modal-shadow: theme("boxShadow.xl");
|
||||
--docsearch-searchbox-height: 3rem;
|
||||
--docsearch-hit-color: theme("colors.slate.700");
|
||||
--docsearch-hit-active-color: theme("colors.sky.600");
|
||||
--docsearch-hit-active-background: theme("colors.slate.100");
|
||||
--docsearch-footer-height: 3rem;
|
||||
--docsearch-border-color: theme("colors.slate.200");
|
||||
--docsearch-input-color: theme("colors.slate.900");
|
||||
--docsearch-heading-color: theme("colors.slate.900");
|
||||
--docsearch-key-background: theme("colors.slate.100");
|
||||
--docsearch-key-hover-background: theme("colors.slate.200");
|
||||
--docsearch-key-color: theme("colors.slate.500");
|
||||
--docsearch-action-color: theme("colors.slate.400");
|
||||
--docsearch-action-active-background: theme("colors.slate.200");
|
||||
--docsearch-loading-background: theme("colors.slate.400");
|
||||
--docsearch-loading-foreground: theme("colors.slate.900");
|
||||
}
|
||||
|
||||
.dark {
|
||||
--docsearch-highlight-color: var(--docsearch-primary-color);
|
||||
--docsearch-muted-color: theme("colors.slate.400");
|
||||
--docsearch-emphasis-color: theme("colors.white");
|
||||
--docsearch-logo-color: theme("colors.slate.300");
|
||||
--docsearch-modal-background: theme("colors.slate.800");
|
||||
--docsearch-modal-shadow: 0 0 0 1px theme("colors.slate.700"), theme("boxShadow.xl");
|
||||
--docsearch-hit-color: theme("colors.slate.300");
|
||||
--docsearch-hit-active-color: theme("colors.sky.400");
|
||||
--docsearch-hit-active-background: rgb(51 65 85 / 0.3);
|
||||
--docsearch-border-color: rgb(148 163 184 / 0.1);
|
||||
--docsearch-heading-color: theme("colors.white");
|
||||
--docsearch-key-background: rgb(51 65 85 / 0.4);
|
||||
--docsearch-key-hover-background: rgb(51 65 85 / 0.8);
|
||||
--docsearch-key-color: theme("colors.slate.400");
|
||||
--docsearch-input-color: theme("colors.white");
|
||||
--docsearch-action-color: theme("colors.slate.500");
|
||||
--docsearch-action-active-background: theme("colors.slate.700");
|
||||
--docsearch-loading-background: theme("colors.slate.500");
|
||||
--docsearch-loading-foreground: theme("colors.white");
|
||||
}
|
||||
|
||||
.DocSearch--active {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.DocSearch-Container {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: -webkit-fill-available;
|
||||
height: calc(var(--docsearch-vh, 1vh) * 100);
|
||||
background-color: rgb(15 23 42 / 0.5);
|
||||
backdrop-filter: blur(theme("backdropBlur.DEFAULT"));
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.DocSearch-Link {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--docsearch-highlight-color);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.DocSearch-Modal {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
height: -webkit-fill-available;
|
||||
height: calc(var(--docsearch-vh, 1vh) * 100);
|
||||
background: var(--docsearch-modal-background);
|
||||
}
|
||||
|
||||
.DocSearch-SearchBar {
|
||||
display: flex;
|
||||
height: var(--docsearch-searchbox-height);
|
||||
border-bottom: 1px solid var(--docsearch-border-color);
|
||||
}
|
||||
|
||||
.DocSearch-Form {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.DocSearch-Input {
|
||||
appearance: none;
|
||||
color: var(--docsearch-input-color);
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
background: transparent;
|
||||
padding: 0 1rem 0 3rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.DocSearch-Input::placeholder {
|
||||
color: theme("colors.slate.400");
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.DocSearch-Input::-webkit-search-cancel-button,
|
||||
.DocSearch-Input::-webkit-search-decoration,
|
||||
.DocSearch-Input::-webkit-search-results-button,
|
||||
.DocSearch-Input::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Reset {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Container--Stalled .DocSearch-MagnifierLabel,
|
||||
.DocSearch-LoadingIndicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Container--Stalled .DocSearch-LoadingIndicator {
|
||||
position: absolute;
|
||||
top: 0.875rem;
|
||||
left: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.DocSearch-LoadingIndicator svg {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.DocSearch-LoadingIndicator path,
|
||||
.DocSearch-LoadingIndicator circle {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.DocSearch-LoadingIndicator circle {
|
||||
stroke: var(--docsearch-loading-background);
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
|
||||
.DocSearch-LoadingIndicator path {
|
||||
stroke: var(--docsearch-loading-foreground);
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
|
||||
.DocSearch-MagnifierLabel {
|
||||
position: absolute;
|
||||
top: 0.875rem;
|
||||
left: 1rem;
|
||||
pointer-events: none;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background: url("data:image/svg+xml,%3Csvg fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16.293 17.707a1 1 0 0 0 1.414-1.414l-1.414 1.414ZM9 14a5 5 0 0 1-5-5H2a7 7 0 0 0 7 7v-2ZM4 9a5 5 0 0 1 5-5V2a7 7 0 0 0-7 7h2Zm5-5a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7v2Zm8.707 12.293-3.757-3.757-1.414 1.414 3.757 3.757 1.414-1.414ZM14 9a4.98 4.98 0 0 1-1.464 3.536l1.414 1.414A6.98 6.98 0 0 0 16 9h-2Zm-1.464 3.536A4.98 4.98 0 0 1 9 14v2a6.98 6.98 0 0 0 4.95-2.05l-1.414-1.414Z' fill='%2394A3B8'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.dark .DocSearch-MagnifierLabel {
|
||||
background: url("data:image/svg+xml,%3Csvg fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16.293 17.707a1 1 0 0 0 1.414-1.414l-1.414 1.414ZM9 14a5 5 0 0 1-5-5H2a7 7 0 0 0 7 7v-2ZM4 9a5 5 0 0 1 5-5V2a7 7 0 0 0-7 7h2Zm5-5a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7v2Zm8.707 12.293-3.757-3.757-1.414 1.414 3.757 3.757 1.414-1.414ZM14 9a4.98 4.98 0 0 1-1.464 3.536l1.414 1.414A6.98 6.98 0 0 0 16 9h-2Zm-1.464 3.536A4.98 4.98 0 0 1 9 14v2a6.98 6.98 0 0 0 4.95-2.05l-1.414-1.414Z' fill='%2364748b'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.DocSearch-MagnifierLabel svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Dropdown {
|
||||
height: 100%;
|
||||
max-height: calc(
|
||||
var(--docsearch-vh, 1vh) * 100 - var(--docsearch-searchbox-height) -
|
||||
var(--docsearch-footer-height)
|
||||
);
|
||||
overflow-y: auto;
|
||||
overflow-y: overlay;
|
||||
padding: 1rem 0.5rem;
|
||||
scrollbar-color: var(--docsearch-muted-color) var(--docsearch-modal-background);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.DocSearch-Dropdown::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.DocSearch-Dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.DocSearch-Dropdown::-webkit-scrollbar-thumb {
|
||||
background-color: var(--docsearch-muted-color);
|
||||
border: 3px solid var(--docsearch-modal-background);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.DocSearch-StartScreen {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.DocSearch-Label {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.DocSearch-Help,
|
||||
.DocSearch-Label {
|
||||
color: var(--docsearch-muted-color);
|
||||
}
|
||||
|
||||
.DocSearch-Help {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.DocSearch-Title {
|
||||
font-size: 0.875rem;
|
||||
color: var(--docsearch-muted-color);
|
||||
}
|
||||
|
||||
.DocSearch-Title strong {
|
||||
color: var(--docsearch-emphasis-color);
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.DocSearch-Logo a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.DocSearch-Logo svg {
|
||||
color: var(--docsearch-logo-color);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.DocSearch-Hits + .DocSearch-Hits {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.DocSearch-Hits mark {
|
||||
background: none;
|
||||
color: var(--docsearch-hit-active-color);
|
||||
}
|
||||
|
||||
.DocSearch-HitsFooter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Hit {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.DocSearch-Hit--deleting,
|
||||
.DocSearch-Hit--favoriting {
|
||||
transform: scale(1);
|
||||
transition: all 0.0001s linear;
|
||||
}
|
||||
|
||||
.DocSearch-Hit a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: theme("borderRadius.lg");
|
||||
}
|
||||
|
||||
.DocSearch-Hit-source,
|
||||
.DocSearch-NoResults .DocSearch-Help {
|
||||
margin-left: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: theme("fontFamily.display");
|
||||
color: var(--docsearch-heading-color);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-Tree {
|
||||
width: 0.5rem;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-Tree * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Hit[aria-selected="true"] a,
|
||||
.DocSearch-Prefill:hover,
|
||||
.DocSearch-Prefill:focus {
|
||||
background-color: var(--docsearch-hit-active-background);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.DocSearch-Hit[aria-selected="true"] mark {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-Container,
|
||||
.DocSearch-Prefill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--docsearch-hit-color);
|
||||
}
|
||||
|
||||
.DocSearch-Hit-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-action {
|
||||
color: var(--docsearch-action-color);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-action + .DocSearch-Hit-action {
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-action-button {
|
||||
border-radius: 50%;
|
||||
color: inherit;
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-action svg {
|
||||
height: 1.125rem;
|
||||
width: 1.125rem;
|
||||
}
|
||||
|
||||
svg.DocSearch-Hit-Select-Icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-Select-Icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-action-button:focus,
|
||||
.DocSearch-Hit-action-button:hover {
|
||||
background: var(--docsearch-action-active-background);
|
||||
}
|
||||
|
||||
.DocSearch-Hit-content-wrapper {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-title,
|
||||
.DocSearch-Prefill {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-path {
|
||||
color: var(--docsearch-muted-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-path,
|
||||
.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-text,
|
||||
.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-title,
|
||||
.DocSearch-Hit[aria-selected="true"] mark,
|
||||
.DocSearch-Prefill:hover,
|
||||
.DocSearch-Prefill:focus {
|
||||
color: var(--docsearch-hit-active-color);
|
||||
}
|
||||
|
||||
.DocSearch-NoResults .DocSearch-Screen-Icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-NoResults .DocSearch-Title {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem 3rem;
|
||||
}
|
||||
|
||||
.DocSearch-NoResults-Prefill-List {
|
||||
margin: 0 -0.5rem;
|
||||
padding: 1rem 0.5rem 0;
|
||||
border-top: 1px solid var(--docsearch-border-color);
|
||||
}
|
||||
|
||||
.DocSearch-Prefill {
|
||||
width: 100%;
|
||||
border-radius: theme("borderRadius.lg");
|
||||
}
|
||||
|
||||
.DocSearch-Footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
height: var(--docsearch-footer-height);
|
||||
z-index: 300;
|
||||
border-top: 1px solid var(--docsearch-border-color);
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.DocSearch-Commands {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Cancel {
|
||||
background: var(--docsearch-key-background);
|
||||
color: var(--docsearch-key-color);
|
||||
align-self: center;
|
||||
flex: none;
|
||||
font-size: 0.75rem;
|
||||
user-select: none;
|
||||
border-radius: theme("borderRadius.md");
|
||||
padding: 0 0.375rem;
|
||||
height: 1.5rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.DocSearch-Cancel:hover {
|
||||
background: var(--docsearch-key-hover-background);
|
||||
}
|
||||
|
||||
@screen sm {
|
||||
.DocSearch-Container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.DocSearch-Modal {
|
||||
height: auto;
|
||||
border-radius: theme("borderRadius.xl");
|
||||
box-shadow: var(--docsearch-modal-shadow);
|
||||
margin: 4rem auto auto;
|
||||
width: auto;
|
||||
max-width: var(--docsearch-modal-width);
|
||||
}
|
||||
|
||||
.DocSearch-Input {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.DocSearch-Footer {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.DocSearch-Commands {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.DocSearch-Commands li {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.DocSearch-Commands li:not(:last-of-type) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.DocSearch-Commands-Key {
|
||||
background: var(--docsearch-key-background);
|
||||
color: var(--docsearch-key-color);
|
||||
width: 1.5rem;
|
||||
height: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: theme("borderRadius.md");
|
||||
margin-right: 0.375rem;
|
||||
}
|
||||
|
||||
.DocSearch-Dropdown {
|
||||
height: auto;
|
||||
max-height: calc(
|
||||
var(--docsearch-modal-height) - var(--docsearch-searchbox-height) -
|
||||
var(--docsearch-footer-height)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.DocSearch-Footer {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.DocSearch-VisuallyHiddenForAccessibility {
|
||||
display: none;
|
||||
}
|
||||
253
app/styles/tailwind.css
Normal file
@ -0,0 +1,253 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply h-full w-full bg-slate-50 antialiased;
|
||||
font-size: 14px;
|
||||
font-family:
|
||||
"Inter V",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: "Circular", sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
html {
|
||||
/*overflow-y: scroll;*/
|
||||
scrollbar-gutter: stable;
|
||||
@apply scroll-smooth;
|
||||
}
|
||||
|
||||
td strong {
|
||||
@apply whitespace-nowrap font-display text-base font-bold text-black;
|
||||
}
|
||||
|
||||
td a {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply whitespace-nowrap font-display text-base text-black;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
/* width */
|
||||
.scrollbar-light::-webkit-scrollbar {
|
||||
@apply h-2 w-2;
|
||||
}
|
||||
|
||||
.scrollbar-light::-webkit-scrollbar-track {
|
||||
@apply fixed bg-transparent;
|
||||
}
|
||||
|
||||
.scrollbar-light::-webkit-scrollbar-thumb {
|
||||
@apply m-2 mr-2 rounded-md border-l border-l-slate-300 bg-slate-200;
|
||||
}
|
||||
|
||||
.scrollbar-light::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-slate-300;
|
||||
}
|
||||
|
||||
h1 a,
|
||||
h2 a,
|
||||
h3 a,
|
||||
h4 a,
|
||||
h5 a,
|
||||
h6 a {
|
||||
font-weight: inherit !important;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dracula Theme originally by Zeno Rocha [@zenorocha]
|
||||
* https://draculatheme.com/
|
||||
*
|
||||
* Ported for PrismJS by Albert Vallverdu [@byverdu]
|
||||
*/
|
||||
|
||||
pre code {
|
||||
@apply !text-[13px];
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply !overflow-x-auto;
|
||||
}
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
@apply whitespace-pre bg-none !px-2 !py-2 text-left text-[13px] text-white;
|
||||
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
|
||||
font-family: "Source Code Pro Variable", Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||
/*word-spacing: normal;*/
|
||||
/*word-break: normal;*/
|
||||
/*word-wrap: normal;*/
|
||||
tab-size: 4;
|
||||
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: 0.5em 0;
|
||||
overflow: hidden;
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background-color: var(--tw-prose-pre-bg);
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: 0.1em;
|
||||
border-radius: 0.3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #6272a4;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #ff79c6;
|
||||
}
|
||||
|
||||
.token.boolean,
|
||||
.token.number {
|
||||
color: #bd93f9;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #50fa7b;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string,
|
||||
.token.variable {
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #f1fa8c;
|
||||
}
|
||||
|
||||
.token.keyword {
|
||||
color: #8be9fd;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important {
|
||||
color: #ffb86c;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspired by gatsby remark prism - https://www.gatsbyjs.com/plugins/gatsby-remark-prismjs/
|
||||
* 1. Make the element just wide enough to fit its content.
|
||||
* 2. Always fill the visible space in .code-highlight.
|
||||
*/
|
||||
.code-highlight {
|
||||
float: left; /* 1 */
|
||||
min-width: 100%; /* 2 */
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: block;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
border-left: 4px solid rgba(0, 0, 0, 0); /* Set placeholder for highlight accent border color to transparent */
|
||||
}
|
||||
|
||||
.code-line.inserted {
|
||||
background-color: rgba(16, 185, 129, 0.2); /* Set inserted line (+) color */
|
||||
}
|
||||
|
||||
.code-line.deleted {
|
||||
background-color: rgba(239, 68, 68, 0.2); /* Set deleted line (-) color */
|
||||
}
|
||||
|
||||
.highlight-line {
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
background-color: rgba(55, 65, 81, 0.5); /* Set highlight bg color */
|
||||
@apply border-l-4 border-l-blue-700;
|
||||
}
|
||||
|
||||
.line-number::before {
|
||||
@apply mr-4 inline-block border-r border-r-slate-700 pr-6 text-right text-slate-500;
|
||||
width: 1rem;
|
||||
content: attr(line);
|
||||
}
|
||||
142
app/utils.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime.js";
|
||||
import duration from "dayjs/plugin/duration.js";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat.js";
|
||||
import updateLocale from "dayjs/plugin/updateLocale.js";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(updateLocale);
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(duration);
|
||||
dayjs.updateLocale("en", {
|
||||
relativeTime: {
|
||||
future: "in %s",
|
||||
past: "%s ago",
|
||||
s: "a few sec.",
|
||||
m: "1 min.",
|
||||
mm: "%d min.",
|
||||
h: "1 hour",
|
||||
hh: "%d hours",
|
||||
d: "1 day",
|
||||
dd: "%d days",
|
||||
M: "a month",
|
||||
MM: "%d months",
|
||||
y: "a year",
|
||||
yy: "%d years",
|
||||
},
|
||||
});
|
||||
|
||||
export const formatters = {
|
||||
date: (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date),
|
||||
|
||||
bytes: (bytes: number, decimals = 2) => {
|
||||
if (!+bytes) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
},
|
||||
|
||||
hertz: (hz: number, decimals = 2) => {
|
||||
if (!+hz) return "0 Hz";
|
||||
|
||||
const k = 1000; // The scaling factor for Hertz is 1000
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Hz", "kHz", "MHz", "GHz", "THz", "PHz", "EHz", "ZHz", "YHz"];
|
||||
|
||||
const i = Math.floor(Math.log(hz) / Math.log(k));
|
||||
|
||||
return `${parseFloat((hz / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
},
|
||||
|
||||
timeAgo: (date: Date, options?: Intl.RelativeTimeFormatOptions) => {
|
||||
const relativeTimeFormat = new Intl.RelativeTimeFormat("en-US", {
|
||||
numeric: "auto",
|
||||
...(options || {}),
|
||||
});
|
||||
|
||||
const DIVISIONS: {
|
||||
amount: number;
|
||||
name: Intl.RelativeTimeFormatUnit;
|
||||
}[] = [
|
||||
{ amount: 60, name: "seconds" },
|
||||
{ amount: 60, name: "minutes" },
|
||||
{ amount: 24, name: "hours" },
|
||||
{ amount: 7, name: "days" },
|
||||
{ amount: 4.34524, name: "weeks" },
|
||||
{ amount: 12, name: "months" },
|
||||
{ amount: Number.POSITIVE_INFINITY, name: "years" },
|
||||
];
|
||||
|
||||
let duration = (date.valueOf() - new Date().valueOf()) / 1000;
|
||||
for (let i = 0; i < DIVISIONS.length; i++) {
|
||||
const division = DIVISIONS[i];
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
return relativeTimeFormat.format(Math.round(duration), division.name);
|
||||
}
|
||||
duration /= division.amount;
|
||||
}
|
||||
},
|
||||
|
||||
price: (price: number | bigint | string, options?: Intl.NumberFormatOptions) => {
|
||||
let opts: Intl.NumberFormatOptions = {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
...(options || {}),
|
||||
};
|
||||
|
||||
// Convert the price to a number for comparison
|
||||
const numericPrice = typeof price === "string" ? parseFloat(price) : Number(price);
|
||||
|
||||
// Check if the price is less than 1 and not zero, then adjust minimumFractionDigits
|
||||
if (numericPrice > 0 && numericPrice < 1) {
|
||||
opts.minimumFractionDigits = Math.max(2, -Math.floor(Math.log10(numericPrice)));
|
||||
} else {
|
||||
opts.minimumFractionDigits = 0;
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("en-US", opts).format(numericPrice);
|
||||
},
|
||||
};
|
||||
|
||||
export function openGraphTags(
|
||||
pageName: string | null,
|
||||
description: string,
|
||||
socialTitle: string,
|
||||
socialDescription: string,
|
||||
) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.append("title", socialTitle);
|
||||
qs.append("description", socialDescription);
|
||||
return [
|
||||
{ title: pageName },
|
||||
{ name: "description", content: description },
|
||||
{ property: "og:title", content: socialTitle },
|
||||
{ property: "og:description", content: socialDescription },
|
||||
{ property: "og:site_name", content: "JetKVM" },
|
||||
{
|
||||
property: "og:image",
|
||||
content: `https://jetkvm.com/og.jpg`,
|
||||
},
|
||||
{ property: "og:type", content: "website" },
|
||||
{ name: "twitter:title", content: pageName },
|
||||
{ name: "twitter:description", content: socialDescription },
|
||||
{ name: "twitter:creator", content: "@jetkvm" },
|
||||
{ name: "twitter:domain", content: "jetkvm.com" },
|
||||
{ name: "twitter:site", content: "@jetkvm" },
|
||||
{ name: "twitter:card", content: "summary_large_image" },
|
||||
{
|
||||
name: "twitter:image",
|
||||
content: `https://jetkvm.com/og.jpg`,
|
||||
},
|
||||
];
|
||||
}
|
||||
14
content/docs/advanced-usage/3d-model.mdx
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
title: "3D Model"
|
||||
order: 3
|
||||
---
|
||||
|
||||
We've made the 3D model of the JetKVM device available for download. This file is useful for customizing the physical hardware, or just exploring the device design in detail.
|
||||
|
||||
You can download the STP file of the JetKVM device <a href="/jetkvm-device-3d-model.stp" download>here</a>.
|
||||
|
||||
### Join the 3D Design Showcase
|
||||
|
||||
We encourage community members to share their 3D designs and modifications in our **#3d-showcase** channel on Discord. Whether you're modifying the JetKVM or building creative extensions, we'd love to see what you come up with!
|
||||
|
||||
Join our Discord community at [jetkvm.com/discord](https://jetkvm.com/discord) to share your designs and connect with other JetKVM enthusiasts!
|
||||
42
content/docs/advanced-usage/developing.mdx
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
title: "Developing"
|
||||
description: "Explore JetKVM's developer features including Developer Mode for SSH access, DFU Mode for firmware updates, and Serial Console for debugging. Learn how to customize and extend JetKVM's functionality."
|
||||
order: 2
|
||||
---
|
||||
|
||||
JetKVM is built with developers in mind, providing tools and modes that allow you to explore, modify, and extend the functionality of the device. If you're interested in tinkering, testing new firmware, or contributing to JetKVM, here's an overview of the key development features.
|
||||
|
||||
## Developer Mode
|
||||
|
||||
Developer Mode unlocks SSH access to the JetKVM device, allowing you to customize or modify the system. When you enable Developer Mode, you must provide an **SSH public key**, as JetKVM uses key-based authentication. **We do not allow password-based SSH logins for security reasons.**
|
||||
|
||||
Once enabled, you can SSH into the JetKVM device, explore the system, and run your own applications. The system runs on a lightweight Linux environment using **BusyBox** as the core user space and **DropBear** as the SSH server.
|
||||
|
||||
## DFU Mode (Device Firmware Update)
|
||||
|
||||
DFU Mode allows you to update the firmware of your JetKVM device, especially when standard access methods like SSH are not available or when testing new firmware. DFU mode provides a way to recover or upgrade the firmware without relying on the standard boot process.
|
||||
|
||||
### How to Enter DFU Mode:
|
||||
|
||||
1. Unplug the USB cable from the device.
|
||||
2. Locate the small hole on the right side of the device.
|
||||
3. Insert a needle into the hole and press & hold the button inside before reconnecting the USB cable.
|
||||
4. Hold the needle for three seconds, then release. Your device is now in DFU Mode.
|
||||
|
||||
### Flashing New Firmware:
|
||||
|
||||
1. Download the firmware update tool for [MacOS & Linux](https://wiki.luckfox.com/Luckfox-Pico/Linux-MacOS-Burn-Image/) or [Windows](https://wiki.luckfox.com/Luckfox-Pico/Luckfox-Pico-RV1106/Luckfox-Pico-Ultra-W/Luckfox-Pico-emmc-burn-image/#driver-installation).
|
||||
2. Compile your firmware into an `.img` file.
|
||||
3. Run the following command in your terminal:
|
||||
|
||||
```sh
|
||||
sudo ./upgrade_tool uf your_firmware.img
|
||||
```
|
||||
|
||||
4. The tool will display progress, and once completed, the JetKVM device will boot with the new firmware.
|
||||
|
||||
## Serial Console
|
||||
|
||||
A serial console provides direct access to system logs, boot messages, and other low-level interactions. It is essential for debugging and monitoring the device during the boot process or when testing new features.
|
||||
|
||||
Since modern computers no longer include traditional serial ports, JetKVM uses a UART splitter to provide serial console access. The UART signal is multiplexed over the SBU (Sideband Use) pins of the USB cable, so you'll need a UART splitter to connect to the serial console.
|
||||
28
content/docs/advanced-usage/factory-reset.mdx
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
title: "Factory Reset"
|
||||
description: "Learn how to perform a factory reset on your JetKVM device using DFU Mode. This guide covers entering DFU Mode and flashing the latest firmware to restore your device to its original settings."
|
||||
order: 1
|
||||
---
|
||||
|
||||
If you need to reset your JetKVM device, this can be done by reflashing the firmware through DFU Mode (Device Firmware Update). This process restores the device by installing the latest firmware, effectively resetting it to factory settings. DFU Mode is essential when you can't access the KVM over SSH or want to start fresh with a clean firmware install.
|
||||
|
||||
## Reset Your JetKVM Using DFU Mode
|
||||
|
||||
To reset the device, you will use DFU Mode, which allows you to flash the firmware directly to the KVM. Instead of compiling custom firmware, you will download the latest official firmware from the JetKVM GitHub repository.
|
||||
|
||||
### Steps to Enter DFU Mode:
|
||||
|
||||
1. Unplug the USB cable from the device.
|
||||
2. Locate the small hole on the right side of the device.
|
||||
3. Insert a needle into the hole and press & hold the button inside before reconnecting the USB cable.
|
||||
4. Hold the needle for three seconds, then release. Your device is now in DFU Mode.
|
||||
|
||||
##### Flashing the Latest Firmware:
|
||||
|
||||
1. Download the firmware update tool from [MacOS & Linux](https://wiki.luckfox.com/Luckfox-Pico/Linux-MacOS-Burn-Image/) or [Windows](https://wiki.luckfox.com/Luckfox-Pico/Luckfox-Pico-RV1106/Luckfox-Pico-Ultra-W/Luckfox-Pico-emmc-burn-image/#driver-installation).
|
||||
2. Download the latest JetKVM firmware from [here](https://api.jetkvm.com/releases/system_recovery/latest).
|
||||
3. Once you have the firmware and update tool, run the following command in your terminal to flash the firmware, while being in DFU Mode:
|
||||
|
||||
```sh
|
||||
sudo ./upgrade_tool uf update.img
|
||||
```
|
||||
33
content/docs/advanced-usage/ota-updates.mdx
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "OTA Updates"
|
||||
description: "Understand JetKVM's over-the-air (OTA) update system, including automatic updates, manual checks, development channel, and rolling releases. Learn how to keep your device up-to-date with the latest features and improvements."
|
||||
order: 2
|
||||
---
|
||||
|
||||
JetKVM supports over-the-air (OTA) updates, which automatically install new software versions to keep your device up-to-date with the latest features, bug fixes, and improvements.
|
||||
|
||||
## Automatic Updates
|
||||
|
||||
By default, **JetKVM is set to automatically update**. The device will periodically check for new updates in the background. If a new update is available, it will be installed automatically. However, JetKVM is designed to avoid disruptions during active use.
|
||||
|
||||
The update system will check for active webRTC connections, and **it will delay updates if you are currently controlling a device**. This ensures that you won't be interrupted in the middle of important tasks. If you have long-running webRTC connections, it's recommended to periodically check for updates manually to ensure your device stays current.
|
||||
|
||||
## Manually Checking for Updates
|
||||
|
||||
If you prefer, you can manually check for updates:
|
||||
|
||||
1. Go to the Settings in the JetKVM web UI.
|
||||
2. Click the Check for Update button to see if any new updates are available.
|
||||
3. If an update is available, you can choose to install it immediately.
|
||||
|
||||
You also have the option to disable automatic updates in the Settings page if you'd prefer to manage updates manually.
|
||||
|
||||
## Rolling Releases
|
||||
|
||||
JetKVM employs a rolling release system for major updates to ensure stability across all devices. **When a new version is released, it is initially rolled out to only 10% of users**. The rollout gradually expands until 100% of devices are updated. This approach helps mitigate risks by preventing widespread issues in case a release encounters unforeseen problems.
|
||||
|
||||
If you notice that a new version has been released but your device hasn't updated yet, you can manually trigger the update by checking for updates in the Settings page. However, **if your device is not part of the current rollout group, the "Check for Update" button will not show any available updates**, even if a new version has been released. You will need to wait for your device to be included in the next phase of the rollout or manually check later.
|
||||
|
||||
## Development Channel (Dev Channel)
|
||||
|
||||
JetKVM offers a development channel for users who want early access to updates. By enabling this option in the Settings sidebar, you will receive development builds before they are rolled out to all users. These builds may include new features, bug fixes, or experimental updates that are still being tested. **Be aware that dev channel updates can be less stable than official releases, so enable it with caution.**
|
||||
80
content/docs/getting-started/faq.mdx
Normal file
@ -0,0 +1,80 @@
|
||||
---
|
||||
title: "Device FAQs"
|
||||
description: "Frequently Asked Questions about the JetKVM Project"
|
||||
order: 4
|
||||
---
|
||||
|
||||
### How do I power the device?
|
||||
|
||||
Please see the [Power Options page](../peripheral-devices/alternative-power-sources).
|
||||
|
||||
### Is there/will there be a PoE version?
|
||||
|
||||
The current JetKVM device does not accept Power over Ethernet, though you may be able to use a USB-C PoE splitter cable,
|
||||
or a PoE extension board.
|
||||
|
||||
A PoE-capable version of JetKVM is on the roadmap.
|
||||
|
||||
### Does it come with Tailscale?
|
||||
|
||||
The device doesn't come with Tailscale pre-installed, though as the device is Linux-based, it should be possible to install.
|
||||
Brandon, a member of the JetKVM community, has created a helpful [Tailscale installer guide on Medium](https://medium.com/@brandontuttle/installing-tailscale-on-a-jetkvm-3c72355b7eb0). Take a look and get started!
|
||||
|
||||
### Can I use the device without the Cloud features?
|
||||
|
||||
**Yes,** the device is primarily designed to be accessed locally. **The cloud access feature is entirely opt-in.**
|
||||
|
||||
Simply point your browser at the IP address shown on the display to access the KVM. You can bring your own VPN
|
||||
solution to access it remotely, if you'd rather not use the cloud.
|
||||
|
||||
### Can I self-host the Cloud features?
|
||||
|
||||
**Absolutely!**
|
||||
|
||||
The JetKVM project will be fully open sourced in the coming days, please see [the Open Source](open-source) page for
|
||||
more information about that specifically.
|
||||
|
||||
You'll be able to run the device/user registry service yourself, it's a simple Node.JS Express based service.
|
||||
JetKVM uses WebRTC for video streaming and control. TURN is used to make this possible over the internet,
|
||||
behind NAT and firewalls.
|
||||
|
||||
### Does it work with KVM Switches?
|
||||
|
||||
KVM switches haven't been tested yet, but there's no outstanding reason why they won't work. We'll update this page
|
||||
as the community starts testing it against their switches.
|
||||
|
||||
### Does it work with _[device]_?
|
||||
|
||||
The JetKVM works independently of the operating system, meaning it doesn't interact directly with the operating system. It works by connecting through HDMI for video and USB for keyboard and mouse input, making it compatible with any system that supports these interfaces.
|
||||
|
||||
So yes, it's fully compatible with _[device]_. No additional configuration or drivers are needed—simply plug it in, and you're good to go!
|
||||
|
||||
### Does the interface work on mobile?
|
||||
|
||||
**Yes!**
|
||||
|
||||
The web interface is written in React and works well on mobile devices; you'll be able to use JetKVM's built in
|
||||
virtual keyboard to control your computer.
|
||||
|
||||
### Is the UI available in any languages other than English?
|
||||
|
||||
**Not yet.** But don't worry, there isn't much text in the UI itself.
|
||||
|
||||
There's an open feature request for UI localisation, and once the code is [made open source](open-source), the community
|
||||
will be able to contribute translations.
|
||||
|
||||
### Can JetKVM control power to the computer?
|
||||
|
||||
**JetKVM has a number of ways to control power to the device it's connected to:**
|
||||
|
||||
- [Wake-on-LAN](wake-on-lan): If your computer is on the same subnet, JetKVM can send it a Wake on LAN Magic Packet to
|
||||
power it up. The computer must support WoL, and it needs to be enabled in the UEFI/BIOS settings.
|
||||
|
||||
- [DC Extension Board](../peripheral-devices/extension-port#dc-power-control): The DC extension board can control power-flow to a computer that has a DC
|
||||
input, as well as extracting power for itself.
|
||||
|
||||
- [ATX Extension Board](../peripheral-devices/extension-port#atx-power-control): The ATX extension board can switch on a PC by triggering its power-switch ATX
|
||||
pins, in the same way the button on the front of your case does!
|
||||
|
||||
**More to Come:** The extension port on JetKVM allows the community to create their own novel ways of controlling power
|
||||
to target devices.
|
||||
61
content/docs/getting-started/ks-faq.mdx
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "Kickstarter FAQs"
|
||||
description: "Frequently Asked Questions about the JetKVM Kickstarter Campaign"
|
||||
order: 5
|
||||
---
|
||||
|
||||
### Can I still make a pledge?
|
||||
|
||||
**Yes!**
|
||||
|
||||
You can make a late pledge to the campaign on [Kickstarter](https://www.kickstarter.com/projects/jetkvm/jetkvm?ref=5sxcqi).
|
||||
Rewards from late pledges will be part of a January or February 2025 batch of devices.
|
||||
|
||||
### Will there be a retail version of JetKVM? How much will it cost?
|
||||
|
||||
**Yes**, we're currently in talks with Amazon to sell the JetKVM.
|
||||
|
||||
We plan to **keep the $69 price**, before shipping and taxes.
|
||||
The final price will vary based on your location and available shipping options.
|
||||
|
||||
### I never provided my shipping address. How do I do that?
|
||||
|
||||
To ensure everything is properly tracked in Kickstarter's system, you'll need to submit your address through the Kickstarter backer survey.
|
||||
|
||||
You should have received an email with the survey link, but you can also access it directly here: [Kickstarter Survey Link](https://www.kickstarter.com/projects/jetkvm/jetkvm/backing/survey_responses) (make sure you're logged into the correct Kickstarter account).
|
||||
|
||||
This process helps us stay organized and ensures your JetKVM is shipped to the right place!
|
||||
|
||||
### When will I receive my JetKVM?
|
||||
|
||||
As the delivery of your JetKVM pledge depends on so many factors, mostly out of our control, we can only provide you with a rough estimate of the dispatch of your device.
|
||||
|
||||
**December Batch:**
|
||||
The first rewards for backers in this group have already started shipping and are being dispatched on an ongoing basis. Additionally, we will start dispatching rewards to backers with extensions in their pledge, starting no later than next week.
|
||||
|
||||
**January Batch:**
|
||||
This batch will begin shipping in January 2025.
|
||||
|
||||
**February Batch:**
|
||||
This batch will begin shipping in February 2025.
|
||||
|
||||
Keep in mind that we don't send out every reward at once, but rather continuously over the target month.
|
||||
|
||||
### How do I know when my package is dispatched?
|
||||
|
||||
Once a package is dispatched from the factory in Shenzhen, China, you will receive a notification email at the shipping email address you provided in the backer survey. Please ensure you've completed the survey.
|
||||
|
||||
We will also send you updates via email as your package makes its way to you. Depending on the courier, the shipping notification email will include a tracking link.
|
||||
|
||||
If you have filled out the survey, but haven't received an email, it means your reward hasn't been shipped yet.
|
||||
|
||||
### What courier will you use?
|
||||
|
||||
Unfortunately, we can't specify the courier for any country, as shipping is handled by a third-party service rather than directly by us. If this is a critical requirement for you, and you'd like a refund, please reach out to [shipping@jetkvm.com](mailto:shipping@jetkvm.com). When we launch on Amazon in February, details like this will be much clearer.
|
||||
|
||||
### Will you be able to handle the demand?
|
||||
|
||||
**Yes!**
|
||||
|
||||
Batch production has been happening throughout November 2024, and we have secured the components needed to meet the
|
||||
projected order volume.
|
||||
24
content/docs/getting-started/open-source.mdx
Normal file
@ -0,0 +1,24 @@
|
||||
---
|
||||
title: "Open Source"
|
||||
description: "JetKVM is an open source project. We welcome contributions from the community to help improve the device and add new features."
|
||||
order: 3
|
||||
---
|
||||
|
||||
We are fully committed to making **all software and source code available under the GPL license.** This includes the complete system image, runtime, and cloud components. We understand that the open-source nature of JetKVM is important to our community, and we want to be transparent about how and when the source code will be released.
|
||||
|
||||
### Timeline for Open Sourcing
|
||||
|
||||
We plan to release the full source code when the **first units of JetKVM are shipped in early December**. Reviewers and early beta testers will have access to the source code prior to this general release to ensure transparency and thorough feedback.
|
||||
|
||||
### What Will Be Open Source
|
||||
|
||||
The following JetKVM components will be made available under the GPL license:
|
||||
|
||||
- **System Image**: The Linux-based operating system running BusyBox in user space.
|
||||
- **KVM Runtime**: The core application, written in Go, responsible for device functionality.
|
||||
- **Local UI**: The web dashboard you interact with through the device's local IP address, providing full access to control and manage the device.
|
||||
- **Cloud API & UI**: The cloud services, including the API and web dashboard, used for remote access and management.
|
||||
|
||||
### Community Contributions
|
||||
|
||||
Once the code is live on our [GitHub](https://github.com/jetkvm), we welcome contributions, issue reports, and pull requests from the community. We believe that open collaboration can significantly improve JetKVM, and we encourage everyone to get involved.
|
||||
51
content/docs/getting-started/quick-start.mdx
Normal file
@ -0,0 +1,51 @@
|
||||
---
|
||||
title: "Quick Start"
|
||||
description: "Get started with JetKVM quickly. Learn how to set up your device, understand the front display information, and familiarize yourself with the available ports for remote computer control."
|
||||
order: 1
|
||||
---
|
||||
|
||||
Getting your JetKVM up and running is easy. Follow these steps to start controlling your target computer remotely.
|
||||
|
||||
1. Attach the **USB-C port** on the back of the JetKVM to a USB port on the computer you wish to control.
|
||||
2. Plug a **Mini HDMI cable** from the **HDMI port** on the JetKVM to the target computer's HDMI port.
|
||||
3. Insert an **Ethernet cable** into the **Ethernet port** on the JetKVM to connect it to your network.
|
||||
4. Plug in the JetKVM. Once powered, the front display will show the device's **IP address**.
|
||||
5. Open a browser and enter the **IP address** displayed on the JetKVM screen.
|
||||
|
||||
You can now control your target computer remotely.
|
||||
|
||||
---
|
||||
|
||||
## Front Display Overview
|
||||
|
||||
<img
|
||||
src="/device-front.png"
|
||||
alt="Front of JetKVM device"
|
||||
style={{ width: "100%", maxWidth: "520px", marginBottom: "32px" }}
|
||||
/>
|
||||
|
||||
The front display of the JetKVM provides key information at a glance. Here’s what each section of the display shows:
|
||||
|
||||
- **Device IP**: The IP address assigned to your by the network, allowing you to access the web UI.
|
||||
- **Device MAC**: The unique MAC address of the device.
|
||||
- **Active Connections**: Displays the number of active connections currently in use.
|
||||
- **HDMI Cable Status**: Shows whether the HDMI connection to the target computer is active.
|
||||
- **USB Cable Status**: Indicates whether the USB connection to the target computer is active.
|
||||
|
||||
This information helps you quickly verify if your JetKVM is properly connected and ready for remote management.
|
||||
|
||||
---
|
||||
|
||||
## Ports Overview
|
||||
|
||||
<img
|
||||
src="/device-back-ports.png"
|
||||
alt="Back of JetKVM device"
|
||||
style={{ width: "100%", maxWidth: "520px", marginBottom: "32px" }}
|
||||
/>
|
||||
Here's a look at the available ports on your JetKVM device:
|
||||
|
||||
- **USB-C**: For connecting to the target computer.
|
||||
- **HDMI Mini**: For receiving the video signal from the target computer.
|
||||
- **Ethernet - RJ45**: For network connectivity.
|
||||
- **Extension Port - RJ11**: For additional features like ATX power control, Serial Console access, or AC/DC power control.
|
||||
67
content/docs/getting-started/troubleshooting.mdx
Normal file
@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Troubleshooting"
|
||||
description: "Troubleshoot common JetKVM issues. Learn how to check connection status, verify USB connections, use the Debug Info Bar, and submit bug reports when needed for smooth remote computer management."
|
||||
order: 1
|
||||
---
|
||||
|
||||
When issues arise with JetKVM, there are a few key steps you can take to identify and resolve the problem. Below, we'll guide you through the general approach to troubleshooting, highlighting the areas to check first and what each status or state means.
|
||||
|
||||
## Check Connection Status
|
||||
|
||||
The first step in troubleshooting is verifying the connection status between your device and the server. JetKVM provides a quick visual check in the **top-right corner of the dashboard**, where you'll see icons that represent the current connection status for both:
|
||||
|
||||
- **WebRTC Connection**: This represents the connection to the server. If this is disrupted, you'll see a message explaining the issue and suggested actions.
|
||||
- **USB Connection**: This shows the status of the USB connection between your JetKVM device and the target host.
|
||||
|
||||
If both connections are **green** and stable but you're still encountering issues, proceed to the next steps.
|
||||
|
||||
## Verify USB Connection
|
||||
|
||||
If the USB connection icon is **grayed out**, there's likely an issue with the USB cable connection. Common reasons include:
|
||||
|
||||
- **USB not attached**: Check the physical connection and make sure the cable is properly inserted.
|
||||
- **USB suspended**: This state usually indicates a low power mode or a suspension due to inactivity.
|
||||
|
||||
For a more detailed state of the USB connection, move to the next step and enable the debug info bar.
|
||||
|
||||
## Enable Debug Info Bar
|
||||
|
||||
If the icons aren't giving enough context or you're still unable to identify the issue, the **Debug Info Bar** in the **Settings** provides deeper insight into what's happening. Here's how to access and use it:
|
||||
|
||||
- **Go to Settings** in the WebUI.
|
||||
- **Enable Debug Info Bar** in the bottom-left corner.
|
||||
|
||||
Once enabled, you'll see additional diagnostic details, such as:
|
||||
|
||||
- **Resolution & Video Size**:
|
||||
|
||||
- _Resolution_: This shows the actual resolution being streamed from the KVM device.
|
||||
- _Video Size_: This reflects the size of the video element on the browser window, adjusting dynamically based on browser dimensions.
|
||||
|
||||
- **Mouse Pointer Position**: This shows the X and Y coordinates of the mouse pointer in real-time, which helps debug input issues.
|
||||
|
||||
- **USB State**: You'll see the raw state of the USB connection:
|
||||
|
||||
- **Configured**: The USB connection is functioning as expected.
|
||||
- **Attached**: The device is physically connected but may still be initializing.
|
||||
- **Not Attached**: The USB device is not detected.
|
||||
- **Suspended**: The connection is idle or in a low-power state.
|
||||
- **Addressed**: The USB device is recognized and assigned an address but not yet fully functional.
|
||||
|
||||
- **HDMI State**: This shows the status of the HDMI signal from the target host:
|
||||
- **Ready**: Everything is functioning properly.
|
||||
- **No Signal**: The target device isn't sending a video signal. Check cables or ensure the device is powered on.
|
||||
- **No Lock/Out of Range**: The signal is either unsupported or outside the allowed range (e.g., resolution or refresh rate too high).
|
||||
- **Connecting**: The HDMI connection is initializing and should be ready soon.
|
||||
|
||||
## When All Else Fails
|
||||
|
||||
If none of the above steps resolve your issue and everything seems to be connected, but JetKVM is still not functioning, we recommend submitting a bug report. Here's how to do that:
|
||||
|
||||
1. **Go to GitHub** and navigate to our issues page. (To be added at launch, when source code is available.)
|
||||
2. **Fill out the bug report template**. Include important information such as:
|
||||
- The **JetKVM app version**.
|
||||
- The **system version** of your KVM device.
|
||||
- A brief description of the problem and context (e.g., when it happens, how often, any notable actions that caused the issue).
|
||||
|
||||
This will help us quickly identify and resolve the problem for you.
|
||||
44
content/docs/index.mdx
Normal file
@ -0,0 +1,44 @@
|
||||
# JetKVM Documentation
|
||||
|
||||
Welcome, to the JetKVM documentation!
|
||||
|
||||
JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations.
|
||||
Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse and keyboard interaction for responsive remote control.
|
||||
- **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC.
|
||||
- **Open-source software** - Written in Golang on Linux. Easily customizable through SSH access to the JetKVM device.
|
||||
|
||||
## Getting help
|
||||
|
||||
We'd love to hear from you or give you a hand getting started. Here are some ways to get in touch with us.
|
||||
|
||||
<div className="not-prose grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ExtLink href="https://jetkvm.com/discord">
|
||||
<GridCard cardClassName="hover:bg-blue-100/50 bg-blue-50/50 hover:!outline-2 hover:!outline-blue-700 transition-all duration-150">
|
||||
<div className="p-6 py-5 h-full">
|
||||
<div className="space-y-1">
|
||||
<DiscordIcon className="h-8 w-8 block text-indigo-600" />
|
||||
<h3 className="block text-base font-bold">Join our Discord</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">Connect with our community, get support, and stay updated on the latest news.</p>
|
||||
</div>
|
||||
</GridCard>
|
||||
</ExtLink>
|
||||
|
||||
<ExtLink href="https://twitter.com/jetkvm">
|
||||
<GridCard cardClassName="hover:bg-blue-100/50 bg-blue-50/50 hover:!outline-2 hover:!outline-blue-700 transition-all duration-150">
|
||||
<div className="p-6 py-5 h-full">
|
||||
<div className="space-y-1 text-black">
|
||||
<svg className="h-6 my-1.5 w-6 block text-black" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
<h3 className="block text-base font-bold">Follow us on X</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">Get quick updates, tips, and engage with the JetKVM team on X (formerly Twitter).</p>
|
||||
</div>
|
||||
</GridCard>
|
||||
</ExtLink>
|
||||
</div>
|
||||
53
content/docs/networking/local-access.mdx
Normal file
@ -0,0 +1,53 @@
|
||||
---
|
||||
title: "Local Access"
|
||||
description: "Learn how to access your JetKVM device locally, set up password protection, reset your password, and manage authentication settings for secure local network access."
|
||||
order: 1
|
||||
---
|
||||
|
||||
To access the JetKVM web UI, you'll need the device's IP address, which is displayed on the front screen of the JetKVM device along with the MAC address. Simply refer to this screen, note the IP address, and use it to navigate to the web UI in your browser.
|
||||
|
||||
## Local Authentication
|
||||
|
||||
Local access is the default access mode for every newly installed JetKVM device. During the onboarding process, you can choose whether or not to password-protect your device for access on the local network.
|
||||
|
||||
### Password Protection
|
||||
|
||||
If you choose not to password-protect the device, anyone on the local network who knows the JetKVM's IP address will be able to access the web UI.
|
||||
For security purposes, it's generally **recommended to enable password protection** to safeguard your device from unauthorized access. The access token is stored as an HTTP-only cookie in the browser for 7 days, making it quite comfortable to enable password protection.
|
||||
|
||||
No part of the authentication data leaves the JetKVM, meaning all access control is handled locally on the device.
|
||||
|
||||
### Changing Password Settings
|
||||
|
||||
You can modify the password settings at any time, either during the onboarding process or later via the settings. The following actions are available:
|
||||
|
||||
- **Enable Password Protection:** If no password was set during onboarding, you can enable it later by providing a password that will be required on the login screen.
|
||||
- **Update Password:** You can update the password if needed by entering a new one.
|
||||
- **Delete Password:** This action removes the password, allowing password-less access to the device.
|
||||
|
||||
## Reset Password
|
||||
|
||||
If you've forgotten your local password, there are two ways to reset it:
|
||||
|
||||
### Option 1: Developer Mode
|
||||
|
||||
If you have **Developer Mode** enabled, you can reset the password by accessing the device via SSH:
|
||||
|
||||
1. **SSH Access:** Use the SSH key you provided when enabling Developer Mode to SSH into the device.
|
||||
|
||||
2. **Delete Config File and Reboot:**
|
||||
|
||||
```bash
|
||||
cd /user_data/ # Navigate to the user_data directory
|
||||
rm kvm_config.json # Delete the configuration file containing the password
|
||||
sync # Ensure file changes are written to the file system
|
||||
reboot # Reboot the device
|
||||
```
|
||||
|
||||
This will reset the configuration file, removing the local password and any other settings. After rebooting the device, you'll need to reconfigure all settings, including enabling **Cloud Mode** if you were using it before.
|
||||
|
||||
### Option 2: Factory Reset
|
||||
|
||||
If you do not have Developer Mode enabled, you can reset your password by performing a factory reset on the device. To do this, simply follow the steps outlined in the [Factory Reset guide](/docs/advanced-usage/factory-reset), which you can find in the Advanced Usage section of our documentation. This process will restore your JetKVM device to its original settings, effectively resetting the password.
|
||||
|
||||
Keep in mind that a factory reset will erase all configurations, including your cloud connection and other settings. After the reset is complete, you'll need to go through the initial setup process again, just as you did when you first received the device. While this method is more drastic than changing a password, it ensures you regain full access to your device.
|
||||
43
content/docs/networking/remote-access.mdx
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
title: "Remote Access"
|
||||
description: "Discover how to enable and use Remote Access for your JetKVM device, allowing secure management from anywhere via WebRTC and OIDC authentication."
|
||||
order: 1
|
||||
---
|
||||
|
||||
Remote Access allows you to connect to your JetKVM device from anywhere on the internet. Through our [cloud dashboard](https://app.jetkvm.com), you can log in, get an overview and control any of your cloud-enabled devices. This means you can access and manage your JetKVM devices from your phone or computer, anywhere in the world with an internet connection.
|
||||
|
||||
## How to enable Remote Access
|
||||
|
||||
By default, Remote Access is turned off, but enabling it is simple:
|
||||
|
||||
1. **Access the JetKVM device locally:** Open your browser and go to the local IP address displayed on the screen of your JetKVM device.
|
||||
2. **Enable Remote Access:** In the top right corner, click **Settings** in the action bar, scroll down and click Enable Remote Access.
|
||||
3. **Authenticate via OIDC:** You will be redirected to an authentication page where you need to log in using your OIDC provider (currently only Google is supported).
|
||||
|
||||
Once authenticated, Remote Access will be enabled, and you will be redirected back to the JetKVM cloud dashboard, where you can securely manage your device from anywhere on the internet.
|
||||
|
||||
## How It Works
|
||||
|
||||
All communication between the device and the browser, whether for local or remote access, is handled through **WebRTC**, ensuring fast and efficient peer-to-peer connections. **WebRTC** provides built-in encryption using DTLS (Datagram Transport Layer Security) for data encryption and SRTP (Secure Real-time Transport Protocol) for media encryption, ensuring that all data, video, and control streams are encrypted end-to-end while in transit.
|
||||
|
||||
<img
|
||||
src="/remote-network-diagram.jpg"
|
||||
alt="Remote access network diagram"
|
||||
style={{ width: "100%", maxWidth: "520px", marginBottom: "32px" }}
|
||||
/>
|
||||
|
||||
## Zero Trust Security Model
|
||||
|
||||
To further enhance security, JetKVM employs a **Zero Trust security model**. In Zero Trust, the principle is to "never trust, always verify." This means the device itself is responsible for determining whether an authentication request is valid—there is no central server involved in verifying credentials. JetKVM uses **OIDC (OpenID Connect)** for authentication, currently supporting only Google accounts. Additional OIDC providers may be added in the future.
|
||||
|
||||
## Stun and Turn Servers
|
||||
|
||||
In most cases, WebRTC establishes direct peer-to-peer connections. However, network restrictions like firewalls or strict NAT policies can prevent direct communication between devices. To solve this, JetKVM, through Cloudflare, provides free STUN and TURN servers for cases where WebRTC requires additional support.
|
||||
|
||||
### STUN Servers
|
||||
|
||||
**STUN (Session Traversal Utilities for NAT)** servers help devices behind NAT (Network Address Translation) determine their public IP address and port. This process facilitates peer-to-peer connections in most scenarios, even when devices are located in different network environments.
|
||||
|
||||
### TURN Servers
|
||||
|
||||
When STUN is not enough, such as in more complex network setups with strict NAT policies, **TURN (Traversal Using Relays around NAT)** servers become necessary. TURN servers act as intermediaries that relay traffic between devices, bypassing firewalls and restrictive NAT configurations. TURN servers are particularly important when direct peer-to-peer communication fails, such as when devices are behind **Carrier-Grade NAT (CGNAT)**, often seen in mobile networks.
|
||||
14
content/docs/networking/wake-on-lan.mdx
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
title: "Wake on Lan"
|
||||
order: 2
|
||||
description: "Learn how to use Wake on LAN with JetKVM to remotely power on or wake up computers on your local network. Discover the convenience of sending magic packets from the web UI for both local and remote access."
|
||||
---
|
||||
|
||||
## Wake on LAN
|
||||
|
||||
Wake on LAN is a standard feature that allows a computer to be powered on or awakened from sleep mode by receiving a network message, known as a "magic packet." This message is sent from one device to another on the local area network (LAN).
|
||||
|
||||
With JetKVM, you can easily send a magic packet to any device on your LAN directly from the web UI. Simply enter the MAC address of the computer you want to wake, and the magic packet will be transmitted to initiate the process.
|
||||
This feature is **available for both local and remote access**, allowing you to wake any compatible device on your LAN whether you’re using JetKVM locally or remotely.
|
||||
|
||||
Please note, the target computer must be configured to listen for incoming magic packets to be awakened.
|
||||
@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Power Options
|
||||
description: "Learn how to extend the functionality of your JetKVM device through the customizable RJ-11 extension port."
|
||||
order: 7
|
||||
---
|
||||
|
||||
## Primary Power Method
|
||||
|
||||
The **most common way to power the JetKVM is through its USB-C port**, connected directly to the computer you're controlling. Many computers have USB ports with constant power, though in some cases, you may need to enable it in the BIOS or UEFI settings (often labeled as "USB Power During Standby" or "USB Always On"), so be sure to check there if it's not working by default.
|
||||
|
||||
## Alternative Power Options
|
||||
|
||||
While USB power is convenient, **we understand that this might not be possible for every situation**. Here are several alternative ways to power your JetKVM device:
|
||||
|
||||
### USB-C Power/Data Splitter (Included)
|
||||
|
||||
The **JetKVM comes with a USB-C Y-cable splitter that separates power and data connections**. This allows you to connect one cable to your remote host for data transmission while powering the device through a separate 5V power supply, such as a phone charger (not included). This solution is perfect when your host computer doesn't provide constant USB power or when you need a more reliable power source.
|
||||
|
||||
### DC Extension
|
||||
|
||||
For more complex setups, the DC Extension option offers dual functionality. Not only will it control the power supply to another computer, but **it also supplies the JetKVM with 5V through the RJ11 cable**. This integrated approach simplifies cable management and is particularly useful in scenarios where you're already using the DC extension for power control.
|
||||
|
||||
### ATX Board Extension
|
||||
|
||||
If you're integrating the JetKVM into a desktop computer setup, you can utilize either the USB-C port or internal pin header on the ATX board. This method supplies 5V to the JetKVM device via the RJ11 cable and is ideal for permanent installations inside a computer case. It's a clean solution that eliminates external power adapters.
|
||||
92
content/docs/peripheral-devices/extension-port.mdx
Normal file
@ -0,0 +1,92 @@
|
||||
---
|
||||
title: "Extension Port"
|
||||
description: "Learn how to extend the functionality of your JetKVM device through the customizable RJ-11 extension port."
|
||||
order: 6
|
||||
---
|
||||
|
||||
The **JetKVM** features an **RJ-11 extension port** on the back, designed to provide full customizability and flexibility to your device. Whether you're looking to integrate additional hardware capabilities or contribute custom add-ons, the extension port opens up unlimited possibilities for expansion.
|
||||
|
||||
<img
|
||||
src="/device-back-port-extension-port-2.png"
|
||||
alt="Back of JetKVM device"
|
||||
style={{ width: "100%", maxWidth: "320px", marginBottom: "40px" }}
|
||||
/>
|
||||
|
||||
## What is the Extension Port?
|
||||
|
||||
The **RJ-11 extension port** functions as a serial port, allowing communication with the JetKVM. This port is key to adding extra hardware and functionality to the device, making it a highly versatile component.
|
||||
|
||||
These add-ons are available for purchase, but the port isn't limited to just JetKVM's accessories—**it's open for anyone to develop their own custom extensions**.
|
||||
|
||||
### ATX Power Control
|
||||
|
||||
<Card className="mb-2 inline-block max-w-[480px] overflow-hidden">
|
||||
<img
|
||||
src="/atx_extension_preview_2.png"
|
||||
alt="ATX Power Control"
|
||||
style={{
|
||||
margin: "0 auto",
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
A control board that manages computer power states. It also includes a USB-C port for connecting
|
||||
a 5V power supply. This can then power the JetKVM through the RJ11 cable. Alternatively, 5V
|
||||
power can be supplied through a pin header.
|
||||
|
||||
The ATX Power Control comes with a PCIe slot cover bracket, allowing for clean cable routing through your PC case - without requiring an actual PCIe slot.
|
||||
|
||||
Each pin features dual headers: one set for KVM control and another for the original front panel buttons—meaning you don't have to choose between them. Both can be connected simultaneously.
|
||||
|
||||
### DC Power Control
|
||||
|
||||
<Card className="mb-2 inline-block max-w-[480px] overflow-hidden">
|
||||
<img
|
||||
src="/dc_extension_preview_2.png"
|
||||
alt="DC Power Control"
|
||||
style={{
|
||||
margin: "0 auto",
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
The DC Power Control extension enables power management for connected DC-powered devices, such as mini PCs or NAS units, using a standard 5.5×2.5 mm connector. It supports DC input ranging from **12 to 20 volts**, and we're **including adapters to support 5.5×2.1 mm** connections as well.
|
||||
|
||||
Included in the extension package:
|
||||
|
||||
- Extension board (pictured) + Case (In Design)
|
||||
- 1x 5.5/2.5 mm male to 5.5/2.5 mm male cable
|
||||
- 1x 5.5/2.1 mm female to 5.5/2.5 mm male adapter
|
||||
- 1x 5.5/2.5 mm female to 5.5/2.1 mm male adapter
|
||||
|
||||
Note: the extension can **also power the JetKVM itself through the RJ-11 connection**, streamlining setup and reducing the need for additional power sources.
|
||||
|
||||
### Serial Console
|
||||
|
||||
Direct access to the device's serial console for diagnostics and low-level control. Unlike the other extensions, that ship with the JetKVM device, this extension will ship separately. We're actively developing it, but a 3D render is not yet available.
|
||||
|
||||
## Customizing with the Extension Port
|
||||
|
||||
By using the extension port, developers and hardware enthusiasts can add a variety of features, for example:
|
||||
|
||||
- **Temperature Sensors**: Monitor temperature for environmental controls.
|
||||
- **Mechanical Button Pressers**: Automate physical interactions with connected devices.
|
||||
|
||||
If you have specific needs for your workflow or want to create fun projects, the extension port provides the flexibility to build and experiment.
|
||||
|
||||
We encourage the community to build custom add-ons for JetKVM.
|
||||
If you create a hardware extension that works with the RJ-11 extension port, you'll be able to **submit a pull request** to the JetKVM project once the source code becomes available in early December when the first devices ship. We enthusiastically welcome contributions from the community and look forward to collaborating on new features that benefit all JetKVM users.
|
||||
|
||||
## Build your own extensions
|
||||
|
||||
The extension port is designed to unlock endless opportunities for hardware modifications. Using a standard RJ-11 connector, it provides power, I2C, and GPIO pins that make it easy to develop your own custom extensions.
|
||||
|
||||
<Card className="mb-2 inline-block max-w-[320px] overflow-hidden">
|
||||
<img
|
||||
src="/extension_port_pinout.png"
|
||||
alt="Extension Port Pinout"
|
||||
style={{ margin: "0 auto" }}
|
||||
/>
|
||||
</Card>
|
||||
We're excited to see what creative solutions you'll build! Whether you're developing something
|
||||
for your personal use or planning to share it with the community, we're committed to supporting
|
||||
developers who want to contribute.
|
||||
47
content/docs/peripheral-devices/keyboard-and-mouse.mdx
Normal file
@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "Keyboard & Mouse"
|
||||
description: "Explore JetKVM's keyboard and mouse features, including mouse modes, the Mouse Jiggler function, and virtual keyboard options. Learn how to optimize your remote input experience for various scenarios."
|
||||
order: 2
|
||||
---
|
||||
|
||||
## Mouse
|
||||
|
||||
### Modes
|
||||
|
||||
JetKVM supports two distinct mouse modes, allowing flexible control depending on your hardware and system configuration:
|
||||
|
||||
- **Absolute Mode**: The input device sends exact X, Y coordinates for the cursor's position. This is commonly used in touchscreens and drawing tablets. JetKVM defaults to absolute mode for its ease of use.
|
||||
- **Relative Mode**: This mode transmits only the relative offset from the current cursor position, which is typical for standard mice. Relative mode is useful for systems where BIOS or UEFI do not support absolute positioning. When using relative mode, the browser will exclusively capture your mouse input when you click within the remote screen.
|
||||
|
||||
**Note**: Relative mode is not yet supported in the current version but is planned for future releases.
|
||||
|
||||
### Jiggler
|
||||
|
||||
The Mouse Jiggler feature simulates small, periodic mouse movements to prevent sleep mode, standby, or screen savers from activating. This is particularly helpful during long-running tasks, like software installations, where manual mouse movement is otherwise needed to prevent the remote system from becoming idle.
|
||||
|
||||
To activate the Mouse Jiggler, navigate to the action bar at the top right of the interface. In the mouse settings section, you'll find an option to toggle "Enable Mouse Jiggler". Simply click this toggle to turn the feature on or off as needed.
|
||||
|
||||
#### How Mouse Jiggler Works
|
||||
|
||||
Once activated, JetKVM monitors the elapsed time since the last user input, whether from the keyboard or mouse. If no user interaction occurs for more than 30 seconds, the jiggler automatically moves the mouse to a set position (currently X:1, Y:1) and repeats this action every 30 seconds.
|
||||
|
||||
Key Points to Note:
|
||||
|
||||
- The jiggler runs independently on the JetKVM device, meaning it continues to function even if the web interface is closed.
|
||||
- The jiggler will not interfere with regular user input. If the user moves the mouse or types within the 30-second window, the jiggler's timer resets. This ensures that the jiggler only activates when there's a period of inactivity.
|
||||
|
||||
### Additional Mouse settings
|
||||
|
||||
- **Hide Cursor**: This option hides the local UI cursor, leaving only the remote system's cursor visible. This feature is useful when you have a fast connection and prefer to focus solely on the remote system's mouse movements.
|
||||
|
||||
## Keyboard
|
||||
|
||||
### Paste from Host
|
||||
|
||||
The "Paste from Host" feature allows you to paste text from your local machine to the remote server you are managing. This functionality is accessible via the action bar, located at the top left of the interface, above the video feed.
|
||||
|
||||
Currently, JetKVM supports only the US keyboard layout, and the host key map defaults to the US setting.
|
||||
|
||||
### Virtual Keyboard
|
||||
|
||||
JetKVM includes a virtual keyboard feature, providing basic keyboard input through the UI. This allows you to send individual keystrokes to the remote machine as needed.
|
||||
55
content/docs/peripheral-devices/mount-drive.mdx
Normal file
@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "Mount Drive"
|
||||
description: "Learn how to use JetKVM's Mount Drive feature to emulate a virtual CD/DVD or disk drive on your target host. Discover supported image formats, drive modes, and the innovative WebRTC streaming process for efficient file access."
|
||||
order: 1
|
||||
---
|
||||
|
||||
The Mount Drive feature allows JetKVM to emulate a read-only virtual CD/DVD or disk drive on the target host. This drive is accessible even during BIOS or UEFI boot, making it useful for tasks like reinstalling an operating system or mounting an ISO to install applications on the target host.
|
||||
|
||||
**JetKVM supports the following image formats: ISO, IMG, QCOW2, WDI, and VMDK.** The system provides two drive modes: CD/DVD Mode and disk Drive Mode. It's important to note that **only one drive can be mounted at a time**, and the drive mode must be set before mounting the image.
|
||||
|
||||
## Mount Drive Methods
|
||||
|
||||
### Storage mount
|
||||
|
||||
Storage mount allows you to mount previously uploaded images from the JetKVM storage to the remote host. This is the fastest way to mount an image, as the JetKVM can provide the image from the JetKVM storage at speeds of peak USB 2.0 speed.
|
||||
|
||||
**We generally recommend using the Storage mount for the fastest performance.** The only downside is that you need to upload the image to the JetKVM storage before you can mount it.
|
||||
|
||||
### URL mount
|
||||
|
||||
URL Mount **streams the image in real time using HTTP(S) from a public web address to the target host through JetKVM.**
|
||||
|
||||
When the target host initiates a file block read, JetKVM fetches the data from the specified URL and streams it directly to the host, functioning like a local drive. This feature allows for convenient access to boot images or data hosted externally, without needing to store them locally.
|
||||
|
||||
JetKVM also **leverages predictive caching, pre-fetching data based on expected read operations.** This ensures faster performance, as the system caches data before the host requests it. The real-time performance of the URL stream can be tracked in the UI, providing insight into block read speeds as they occur.
|
||||
|
||||
To streamline the process, JetKVM includes a list of popular image URLs for quick mounting. Users can select these pre-configured URLs or provide their own.
|
||||
|
||||
### Browser mount
|
||||
|
||||
Browser mount **streams the image in real time using WebRTC from the browser, to the target host, through the JetKVM.**
|
||||
|
||||
When the target host performs a file block read, JetKVM forwards that request over WebRTC to the client's local system. The local system then reads the necessary data and sends it back to the target host.
|
||||
This streaming process ensures that the image is mounted instantaneously. However, it's important to note that the tab must remain open for the streaming to continue, as the data is being transmitted in real-time.
|
||||
|
||||
To optimize performance, **JetKVM uses a predictive pre-caching mechanism to anticipate where the target host will perform read operations.** This pre-streaming accelerates the reading process, as data is pre-read and cached before the request reaches the target host.
|
||||
|
||||
## USB Modes
|
||||
|
||||
JetKVM offers two distinct USB modes for mounting images on a target host: **CD/DVD Mode** and **Disk Mode**. Choosing the right mode is key to ensuring compatibility and optimal performance for tasks such as operating system installations and software deployments.
|
||||
|
||||
### CD/DVD Mode
|
||||
|
||||
In CD/DVD Mode, the image is emulated as a virtual optical drive (CD or DVD). This mode is particularly useful for systems or installers that require the media to be presented as a CD or DVD, such as specific Windows installers or software that needs optical media emulation.
|
||||
|
||||
Unlike some other KVMs, JetKVM's CD/DVD Mode can handle images larger than 2.2GB, allowing you to mount larger images as an virtual optical drive, without any issues.
|
||||
|
||||
### Disk Mode
|
||||
|
||||
In Disk Mode, the mounted image is emulated as a virtual USB disk drive. This mode is best suited for:
|
||||
|
||||
- **Modern Boot Methods:** Many systems today boot from USB disk drives, making Disk Mode a convenient choice for installations, live environments, or recovery tasks.
|
||||
- **Large Images:** It supports images larger than 2.2GB, making it ideal for modern operating system installations.
|
||||
|
||||
However, some images, especially certain OS installers, may not work in Disk Mode due to how they are structured or limitations in the target system's BIOS or UEFI firmware. In those cases, you can try using CD/DVD Mode instead.
|
||||
13
content/docs/video/hdmi-edid.mdx
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
title: "HDMI EDID"
|
||||
description: "Understand JetKVM's EDID (Extended Display Identification Data) configuration options. Learn when and how to modify EDID settings for optimal display compatibility with remote devices."
|
||||
order: 2
|
||||
---
|
||||
|
||||
## EDID
|
||||
|
||||
EDID (Extended Display Identification Data) is the information about video modes supported by the video capture device, in this case, JetKVM. This data helps your JetKVM communicate with connected devices to determine the best display settings automatically. **The default EDID configuration is flexible enough for most scenarios**, so typically, you won't need to modify it.
|
||||
|
||||
However, in certain cases, like when dealing with unusual UEFI or BIOS configurations, you may need to adjust the EDID settings. To do this, simply access the Settings in the JetKVM WebUI, where you'll find a dedicated section for EDID configuration.
|
||||
|
||||
You can either select from a list of predefined EDID presets that we provide or input your own custom EDID.
|
||||
9
content/docs/video/video-quality.mdx
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Video Quality"
|
||||
description: "Learn about JetKVM's video quality settings. Understand how to adjust video stream bitrate for optimal performance based on your connection speed and resolution requirements."
|
||||
order: 1
|
||||
---
|
||||
|
||||
**JetKVM provides a video quality toggle with three options: High, Medium, and Low**, allowing you to adjust the video stream's bitrate based on your connection speed and resolution needs. High quality delivers the clearest video for fast connections, medium quality balances performance and visual clarity, and low quality minimizes bandwidth usage for slower networks.
|
||||
|
||||
The system automatically calculates the optimal bitrate for each setting, ensuring smooth performance during remote sessions, with a minimum bitrate of 100 kbps to maintain functionality.
|
||||
3
env.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="@remix-run/node" />
|
||||
/// <reference types="@remix-run/node" />
|
||||
31784
package-lock.json
generated
Normal file
81
package.json
Normal file
@ -0,0 +1,81 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"name": "jetkvm",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "remix vite:build",
|
||||
"dev": "remix vite:dev",
|
||||
"start": "remix-serve ./build/server/index.js",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/source-code-pro": "^5.0.18",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@hookform/resolvers": "^3.6.0",
|
||||
"@nasa-gcn/remix-seo": "^2.0.0",
|
||||
"@remix-run/node": "2.8.1",
|
||||
"@remix-run/react": "2.8.1",
|
||||
"@remix-run/serve": "2.8.1",
|
||||
"@resvg/resvg-js": "^2.6.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@tanstack/react-table": "^8.15.0",
|
||||
"@tanstack/react-virtual": "^3.2.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cva": "^1.0.0-beta.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"esbuild": "^0.20.2",
|
||||
"extract-md-headings": "^0.2.7",
|
||||
"hashids": "^2.3.0",
|
||||
"isbot": "^3.7.1",
|
||||
"mdx-bundler": "^10.0.1",
|
||||
"mini-svg-data-uri": "^1.4.4",
|
||||
"nanoid": "^4.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-email": "^2.1.0",
|
||||
"react-hook-form": "^7.51.1",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.24",
|
||||
"react-window": "^1.8.10",
|
||||
"react-window-infinite-loader": "^1.0.9",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-prism-plus": "^2.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remix-utils": "^7.5.0",
|
||||
"satori": "^0.10.13",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"type-fest": "^4.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docsearch/react": "^3.5.2",
|
||||
"@remix-run/dev": "2.8.1",
|
||||
"@remix-run/eslint-config": "2.8.1",
|
||||
"@types/react": "^18.2.70",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/react-window-infinite-loader": "^1.0.9",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.12",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.6",
|
||||
"vite-env-only": "^2.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
11
prettier.config.mjs
Normal file
@ -0,0 +1,11 @@
|
||||
export default {
|
||||
trailingComma: "all",
|
||||
tabWidth: 2,
|
||||
semi: true,
|
||||
useTabs: false,
|
||||
arrowParens: "avoid",
|
||||
singleQuote: false,
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
tailwindFunctions: ["clsx"],
|
||||
printWidth: 90,
|
||||
};
|
||||
BIN
public/atx_extension_preview.png
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
public/atx_extension_preview_2.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
public/bg-noise.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
public/bg.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/dc_extension_preview.png
Normal file
|
After Width: | Height: | Size: 539 KiB |
BIN
public/dc_extension_preview_2.png
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
public/device-back-port-extension-port-2.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/device-back-ports.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
public/device-front.png
Normal file
|
After Width: | Height: | Size: 602 KiB |
BIN
public/extension_port_pinout.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |