Release 202412302114

This commit is contained in:
Adam Shiervani 2024-10-20 22:36:02 +02:00
commit e82effb2f0
129 changed files with 658626 additions and 0 deletions

4
.eslintrc.mjs Normal file
View 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
View 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
View 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
View 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
View 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)
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](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
```

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

31
app/components/Alert.tsx Normal file
View 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
View 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
View 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>
);
}

View 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,
});

View 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>
);
}

View 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
View 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>
);

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
});

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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,
)}
</>
);
}

View 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
View 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
View 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
View 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
View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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;

View 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
View 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
View File

@ -0,0 +1,5 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/node";
export function loader() {
return redirect("/docs/getting-started/faq");
}

View File

@ -0,0 +1,5 @@
import { redirect } from "@remix-run/node";
export function loader() {
return redirect("https://jetkvm.com/docs/getting-started/quick-start");
}

View 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
View 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
View 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
View 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",
);
}

View 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
View 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;
};

View 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
View 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
View 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
View 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`,
},
];
}

View 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!

View 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.

View 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
```

View 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.**

View 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.

View 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.

View 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.

View 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. Heres 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.

View 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
View 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>

View 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.

View 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.

View 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 youre using JetKVM locally or remotely.
Please note, the target computer must be configured to listen for incoming magic packets to be awakened.

View File

@ -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.

View 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.

View 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.

View 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.

View 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.

View 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
View 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

File diff suppressed because it is too large Load Diff

81
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
autoprefixer: {},
tailwindcss: {},
},
};

11
prettier.config.mjs Normal file
View 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,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

BIN
public/bg-noise.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
public/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

BIN
public/device-front.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More