Release 202412292127
22
ui/src/api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
function api(url: string, options: RequestInit): Promise<Response> {
|
||||
const baseOptions: RequestInit = {
|
||||
mode: "cors",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
return fetch(url, baseOptions);
|
||||
}
|
||||
|
||||
export default Object.assign(api, {
|
||||
GET: (url: string, options?: RequestInit) => api(url, { method: "GET", ...options }),
|
||||
POST: (url: string, body?: object, options?: RequestInit) =>
|
||||
api(url, { method: "POST", body: JSON.stringify(body), ...options }),
|
||||
PUT: (url: string, body?: object, options?: RequestInit) =>
|
||||
api(url, { method: "PUT", body: JSON.stringify(body), ...options }),
|
||||
DELETE: (url: string, body?: object, options?: RequestInit) =>
|
||||
api(url, { method: "DELETE", body: JSON.stringify(body), ...options }),
|
||||
});
|
||||
BIN
ui/src/assets/arch-icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
8
ui/src/assets/attach-icon.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 6C2 4.89543 2.89543 4 4 4H20C21.1046 4 22 4.89543 22 6V18C22 19.1046 21.1046 20 20 20H4C2.89543 20 2 19.1046 2 18V6Z"
|
||||
fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M20 6H4V18H20V6ZM4 4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V6C22 4.89543 21.1046 4 20 4H4Z"
|
||||
fill="black"/>
|
||||
<path d="M4 13H20V18H4V13Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 511 B |
BIN
ui/src/assets/centos-icon.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
ui/src/assets/coreos-icon.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
ui/src/assets/debian-icon.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
15
ui/src/assets/detach-icon.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2 7C2 5.89543 2.89543 5 4 5H20C21.1046 5 22 5.89543 22 7V19C22 20.1046 21.1046 21 20 21H4C2.89543 21 2 20.1046 2 19V7Z"
|
||||
fill="transparent" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M20 7H4V19H20V7ZM4 5C2.89543 5 2 5.89543 2 7V19C2 20.1046 2.89543 21 4 21H20C21.1046 21 22 20.1046 22 19V7C22 5.89543 21.1046 5 20 5H4Z"
|
||||
fill="currentColor" />
|
||||
<path d="M12 3H24V15H12V3Z" fill="transparent" />
|
||||
<path
|
||||
d="M14 6C14 5.44772 14.4477 5 15 5H21C21.5523 5 22 5.44772 22 6V12C22 12.5523 21.5523 13 21 13H15C14.4477 13 14 12.5523 14 12V6Z"
|
||||
fill="transparent" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M16 7V11H20V7H16ZM15 5C14.4477 5 14 5.44772 14 6V12C14 12.5523 14.4477 13 15 13H21C21.5523 13 22 12.5523 22 12V6C22 5.44772 21.5523 5 21 5H15Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 991 B |
BIN
ui/src/assets/fedora-icon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
ui/src/assets/gentoo-icon.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
ui/src/assets/jetkvm-device-still.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
ui/src/assets/kali-icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
ui/src/assets/keyboard-and-mouse-connected.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
ui/src/assets/logo-blue.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
12
ui/src/assets/logo-blue.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="89" height="24" viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="6" fill="#1D4ED8"/>
|
||||
<path d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z" fill="#1D4ED8"/>
|
||||
<path d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z" fill="white"/>
|
||||
<path d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z" fill="#1D4ED8"/>
|
||||
<path d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z" fill="white"/>
|
||||
<path d="M28.32 13.84L30.192 13.456V14.512C30.192 15.184 30.3573 15.6747 30.688 15.984C31.0187 16.2827 31.4347 16.432 31.936 16.432C32.448 16.432 32.8533 16.272 33.152 15.952C33.4507 15.6213 33.6 15.168 33.6 14.592V6.656H35.52V14.544C35.52 15.0453 35.4347 15.52 35.264 15.968C35.0933 16.416 34.8533 16.8107 34.544 17.152C34.2347 17.4827 33.8613 17.7493 33.424 17.952C32.9867 18.144 32.496 18.24 31.952 18.24C31.3973 18.24 30.896 18.1547 30.448 17.984C30 17.8027 29.616 17.552 29.296 17.232C28.9867 16.912 28.7467 16.528 28.576 16.08C28.4053 15.632 28.32 15.1307 28.32 14.576V13.84ZM42.9919 13.248C42.9812 13.024 42.9332 12.8107 42.8479 12.608C42.7732 12.3947 42.6559 12.208 42.4959 12.048C42.3359 11.888 42.1385 11.76 41.9039 11.664C41.6692 11.568 41.3919 11.52 41.0719 11.52C40.7839 11.52 40.5225 11.5733 40.2879 11.68C40.0639 11.776 39.8719 11.9093 39.7119 12.08C39.5519 12.24 39.4239 12.4267 39.3279 12.64C39.2319 12.8427 39.1785 13.0453 39.1679 13.248H42.9919ZM44.7679 15.776C44.6612 16.1173 44.5065 16.4373 44.3039 16.736C44.1012 17.0347 43.8505 17.296 43.5519 17.52C43.2532 17.744 42.9119 17.92 42.5279 18.048C42.1439 18.176 41.7172 18.24 41.2479 18.24C40.7145 18.24 40.2079 18.1493 39.7279 17.968C39.2479 17.776 38.8265 17.504 38.4639 17.152C38.1012 16.7893 37.8079 16.352 37.5839 15.84C37.3705 15.3173 37.2639 14.7253 37.2639 14.064C37.2639 13.4453 37.3652 12.8853 37.5679 12.384C37.7812 11.8827 38.0639 11.456 38.4159 11.104C38.7679 10.7413 39.1732 10.464 39.6319 10.272C40.0905 10.0693 40.5652 9.968 41.0559 9.968C41.6532 9.968 42.1865 10.064 42.6559 10.256C43.1359 10.448 43.5359 10.72 43.8559 11.072C44.1865 11.424 44.4372 11.8507 44.6079 12.352C44.7785 12.8427 44.8639 13.3973 44.8639 14.016C44.8639 14.1653 44.8585 14.2987 44.8479 14.416C44.8372 14.5227 44.8265 14.5867 44.8159 14.608H39.1199C39.1305 14.9067 39.1945 15.1787 39.3119 15.424C39.4292 15.6693 39.5839 15.8827 39.7759 16.064C39.9679 16.2453 40.1865 16.3893 40.4319 16.496C40.6879 16.592 40.9599 16.64 41.2479 16.64C41.8132 16.64 42.2452 16.512 42.5439 16.256C42.8532 15.9893 43.0719 15.664 43.1999 15.28L44.7679 15.776ZM48.9895 10.208H50.6055V11.856H48.9895V15.472C48.9895 15.8133 49.0695 16.064 49.2295 16.224C49.3895 16.3733 49.6402 16.448 49.9815 16.448C50.1095 16.448 50.2375 16.4427 50.3655 16.432C50.4935 16.4107 50.5788 16.3947 50.6215 16.384V17.92C50.5682 17.9413 50.4508 17.9733 50.2695 18.016C50.0882 18.0693 49.8268 18.096 49.4855 18.096C48.7602 18.096 48.1895 17.8933 47.7735 17.488C47.3575 17.0827 47.1495 16.512 47.1495 15.776V11.856H45.7095V10.208H46.1095C46.5255 10.208 46.8295 10.0907 47.0215 9.856C47.2135 9.62133 47.3095 9.33333 47.3095 8.992V7.824H48.9895V10.208ZM56.0653 13.088L54.5613 14.736V18H52.6413V6.656H54.5613V12.096L59.4413 6.656H61.9693L57.3773 11.664L62.0173 18H59.6013L56.0653 13.088ZM70.6701 6.656H72.7021L68.4141 18H66.4621L62.2381 6.656H64.3181L67.4861 15.488L70.6701 6.656ZM84.7954 18V9.648L81.2594 18H79.5954L76.0914 9.68V18H74.2194V6.656H76.7794L80.4594 15.312L84.0914 6.656H86.6994V18H84.7954Z" fill="#1D4ED8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
BIN
ui/src/assets/logo-mark.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
30
ui/src/assets/logo-white.svg
Normal file
@@ -0,0 +1,30 @@
|
||||
<svg viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="6" fill="#1D4ED8" />
|
||||
<path
|
||||
d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z"
|
||||
fill="#1D4ED8" />
|
||||
<path
|
||||
d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z"
|
||||
fill="white" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z"
|
||||
fill="white" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z"
|
||||
fill="white" />
|
||||
<path
|
||||
d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z"
|
||||
fill="#1D4ED8" />
|
||||
<path
|
||||
d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z"
|
||||
fill="white" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z"
|
||||
fill="white" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z"
|
||||
fill="white" />
|
||||
<path
|
||||
d="M28.32 13.84L30.192 13.456V14.512C30.192 15.184 30.3573 15.6747 30.688 15.984C31.0187 16.2827 31.4347 16.432 31.936 16.432C32.448 16.432 32.8533 16.272 33.152 15.952C33.4507 15.6213 33.6 15.168 33.6 14.592V6.656H35.52V14.544C35.52 15.0453 35.4347 15.52 35.264 15.968C35.0933 16.416 34.8533 16.8107 34.544 17.152C34.2347 17.4827 33.8613 17.7493 33.424 17.952C32.9867 18.144 32.496 18.24 31.952 18.24C31.3973 18.24 30.896 18.1547 30.448 17.984C30 17.8027 29.616 17.552 29.296 17.232C28.9867 16.912 28.7467 16.528 28.576 16.08C28.4053 15.632 28.32 15.1307 28.32 14.576V13.84ZM42.9919 13.248C42.9812 13.024 42.9332 12.8107 42.8479 12.608C42.7732 12.3947 42.6559 12.208 42.4959 12.048C42.3359 11.888 42.1385 11.76 41.9039 11.664C41.6692 11.568 41.3919 11.52 41.0719 11.52C40.7839 11.52 40.5225 11.5733 40.2879 11.68C40.0639 11.776 39.8719 11.9093 39.7119 12.08C39.5519 12.24 39.4239 12.4267 39.3279 12.64C39.2319 12.8427 39.1785 13.0453 39.1679 13.248H42.9919ZM44.7679 15.776C44.6612 16.1173 44.5065 16.4373 44.3039 16.736C44.1012 17.0347 43.8505 17.296 43.5519 17.52C43.2532 17.744 42.9119 17.92 42.5279 18.048C42.1439 18.176 41.7172 18.24 41.2479 18.24C40.7145 18.24 40.2079 18.1493 39.7279 17.968C39.2479 17.776 38.8265 17.504 38.4639 17.152C38.1012 16.7893 37.8079 16.352 37.5839 15.84C37.3705 15.3173 37.2639 14.7253 37.2639 14.064C37.2639 13.4453 37.3652 12.8853 37.5679 12.384C37.7812 11.8827 38.0639 11.456 38.4159 11.104C38.7679 10.7413 39.1732 10.464 39.6319 10.272C40.0905 10.0693 40.5652 9.968 41.0559 9.968C41.6532 9.968 42.1865 10.064 42.6559 10.256C43.1359 10.448 43.5359 10.72 43.8559 11.072C44.1865 11.424 44.4372 11.8507 44.6079 12.352C44.7785 12.8427 44.8639 13.3973 44.8639 14.016C44.8639 14.1653 44.8585 14.2987 44.8479 14.416C44.8372 14.5227 44.8265 14.5867 44.8159 14.608H39.1199C39.1305 14.9067 39.1945 15.1787 39.3119 15.424C39.4292 15.6693 39.5839 15.8827 39.7759 16.064C39.9679 16.2453 40.1865 16.3893 40.4319 16.496C40.6879 16.592 40.9599 16.64 41.2479 16.64C41.8132 16.64 42.2452 16.512 42.5439 16.256C42.8532 15.9893 43.0719 15.664 43.1999 15.28L44.7679 15.776ZM48.9895 10.208H50.6055V11.856H48.9895V15.472C48.9895 15.8133 49.0695 16.064 49.2295 16.224C49.3895 16.3733 49.6402 16.448 49.9815 16.448C50.1095 16.448 50.2375 16.4427 50.3655 16.432C50.4935 16.4107 50.5788 16.3947 50.6215 16.384V17.92C50.5682 17.9413 50.4508 17.9733 50.2695 18.016C50.0882 18.0693 49.8268 18.096 49.4855 18.096C48.7602 18.096 48.1895 17.8933 47.7735 17.488C47.3575 17.0827 47.1495 16.512 47.1495 15.776V11.856H45.7095V10.208H46.1095C46.5255 10.208 46.8295 10.0907 47.0215 9.856C47.2135 9.62133 47.3095 9.33333 47.3095 8.992V7.824H48.9895V10.208ZM56.0653 13.088L54.5613 14.736V18H52.6413V6.656H54.5613V12.096L59.4413 6.656H61.9693L57.3773 11.664L62.0173 18H59.6013L56.0653 13.088ZM70.6701 6.656H72.7021L68.4141 18H66.4621L62.2381 6.656H64.3181L67.4861 15.488L70.6701 6.656ZM84.7954 18V9.648L81.2594 18H79.5954L76.0914 9.68V18H74.2194V6.656H76.7794L80.4594 15.312L84.0914 6.656H86.6994V18H84.7954Z"
|
||||
fill="white" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
BIN
ui/src/assets/monitor-connected.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
11
ui/src/assets/mouse-icon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3161_210)">
|
||||
<path d="M11.5 1V7.60833H17.3333C17.3333 4.20833 14.7917 1.40833 11.5 1ZM4 12.6083C4 16.2917 6.98333 19.275 10.6667 19.275C14.35 19.275 17.3333 16.2917 17.3333 12.6083V9.275H4V12.6083ZM9.83333 1C6.54167 1.40833 4 4.20833 4 7.60833H9.83333V1Z"
|
||||
fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3161_210">
|
||||
<rect width="20" height="20" fill="white" transform="translate(0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 575 B |
18
ui/src/assets/netboot-icon.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
ui/src/assets/opensuse-icon.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
11
ui/src/assets/pointing-finger.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3161_200)">
|
||||
<path d="M7.49967 9.36667V6.25C7.49967 5.69747 7.71917 5.16756 8.10987 4.77686C8.50057 4.38616 9.03047 4.16667 9.58301 4.16667C10.1355 4.16667 10.6654 4.38616 11.0561 4.77686C11.4468 5.16756 11.6663 5.69747 11.6663 6.25V9.36667C12.6747 8.69167 13.333 7.55 13.333 6.25C13.333 4.175 11.658 2.5 9.58301 2.5C7.50801 2.5 5.83301 4.175 5.83301 6.25C5.83301 7.55 6.49134 8.69167 7.49967 9.36667ZM15.6997 13.225L11.9163 11.3417C11.7747 11.2833 11.6247 11.25 11.4663 11.25H10.833V6.25C10.833 5.55833 10.2747 5 9.58301 5C8.89134 5 8.33301 5.55833 8.33301 6.25V15.2C5.33301 14.5667 5.38301 14.575 5.27467 14.575C5.01634 14.575 4.78301 14.6833 4.61634 14.85L3.95801 15.5167L8.07467 19.6333C8.29967 19.8583 8.61634 20 8.95801 20H14.6163C15.2413 20 15.7247 19.5417 15.8163 18.9333L16.4413 14.5417C16.4497 14.4833 16.458 14.425 16.458 14.375C16.458 13.8583 16.1413 13.4083 15.6997 13.225Z"
|
||||
fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3161_200">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
ui/src/assets/ubuntu-icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
ui/src/assets/windows-icon.png
Normal file
|
After Width: | Height: | Size: 1005 B |
258
ui/src/components/ActionBar.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { Button } from "@components/Button";
|
||||
import {
|
||||
useHidStore,
|
||||
useMountMediaStore,
|
||||
useUiStore,
|
||||
useSettingsStore,
|
||||
} from "@/hooks/stores";
|
||||
import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||
import Container from "@components/Container";
|
||||
import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
||||
import { cx } from "@/cva.config";
|
||||
import PasteModal from "@/components/popovers/PasteModal";
|
||||
import { FaKeyboard } from "react-icons/fa6";
|
||||
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import MountPopopover from "./popovers/MountPopover";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export default function Actionbar({
|
||||
requestFullscreen,
|
||||
}: {
|
||||
requestFullscreen: () => Promise<void>;
|
||||
}) {
|
||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||
|
||||
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const enableTerminal = useUiStore(state => state.enableTerminal);
|
||||
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
|
||||
const remoteVirtualMediaState = useMountMediaStore(
|
||||
state => state.remoteVirtualMediaState,
|
||||
);
|
||||
const developerMode = useSettingsStore(state => state.developerMode);
|
||||
|
||||
// This is the only way to get a reliable state change for the popover
|
||||
// at time of writing this there is no mount, or unmount event for the popover
|
||||
const isOpen = useRef<boolean>(false);
|
||||
const checkIfStateChanged = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open !== isOpen.current) {
|
||||
isOpen.current = open;
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
setDisableFocusTrap(false);
|
||||
console.log("Popover is closing. Returning focus trap to video");
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
[setDisableFocusTrap],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container className="bg-white border-b border-b-slate-800/20 dark:bg-slate-900 dark:border-b-slate-300/20">
|
||||
<div
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
|
||||
>
|
||||
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||
{developerMode && (
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Web Terminal"
|
||||
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
|
||||
onClick={() => setEnableTerminal(!enableTerminal)}
|
||||
/>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverButton as={Fragment}>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Paste text"
|
||||
LeadingIcon={MdOutlineContentPasteGo}
|
||||
onClick={() => {
|
||||
setDisableFocusTrap(true);
|
||||
}}
|
||||
/>
|
||||
</PopoverButton>
|
||||
<PopoverPanel
|
||||
anchor="bottom start"
|
||||
transition
|
||||
className={cx(
|
||||
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
|
||||
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
|
||||
)}
|
||||
>
|
||||
{({ open }) => {
|
||||
checkIfStateChanged(open);
|
||||
return (
|
||||
<div className="w-full max-w-xl mx-auto">
|
||||
<PasteModal />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
<div className="relative">
|
||||
<Popover>
|
||||
<PopoverButton as={Fragment}>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Virtual Media"
|
||||
LeadingIcon={({ className }) => {
|
||||
return (
|
||||
<>
|
||||
<LuHardDrive className={className} />
|
||||
<div
|
||||
className={cx(className, "h-2 w-2 rounded-full bg-blue-700", {
|
||||
hidden: !remoteVirtualMediaState,
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
onClick={() => {
|
||||
setDisableFocusTrap(true);
|
||||
}}
|
||||
/>
|
||||
</PopoverButton>
|
||||
<PopoverPanel
|
||||
anchor="bottom start"
|
||||
transition
|
||||
className={cx(
|
||||
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
|
||||
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
|
||||
)}
|
||||
>
|
||||
{({ open }) => {
|
||||
checkIfStateChanged(open);
|
||||
return (
|
||||
<div className="w-full max-w-xl mx-auto">
|
||||
<MountPopopover />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<Popover>
|
||||
<PopoverButton as={Fragment}>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Wake on Lan"
|
||||
onClick={() => {
|
||||
setDisableFocusTrap(true);
|
||||
}}
|
||||
LeadingIcon={({ className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z" />
|
||||
<path d="M6 8v1" />
|
||||
<path d="M10 8v1" />
|
||||
<path d="M14 8v1" />
|
||||
<path d="M18 8v1" />
|
||||
</svg>
|
||||
)}
|
||||
/>
|
||||
</PopoverButton>
|
||||
<PopoverPanel
|
||||
anchor="bottom start"
|
||||
transition
|
||||
style={{
|
||||
transitionProperty: "opacity",
|
||||
}}
|
||||
className={cx(
|
||||
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
|
||||
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
|
||||
)}
|
||||
>
|
||||
{({ open }) => {
|
||||
checkIfStateChanged(open);
|
||||
return (
|
||||
<div className="w-full max-w-xl mx-auto">
|
||||
<WakeOnLanModal />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Virtual Keyboard"
|
||||
LeadingIcon={FaKeyboard}
|
||||
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||
<div className="block lg:hidden">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Virtual Keyboard"
|
||||
LeadingIcon={FaKeyboard}
|
||||
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Connection Stats"
|
||||
LeadingIcon={({ className }) => (
|
||||
<LuSignal
|
||||
className={cx(className, "mb-0.5 text-green-500")}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
)}
|
||||
onClick={() => {
|
||||
toggleSidebarView("connection-stats");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hidden xs:block ">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Settings"
|
||||
LeadingIcon={LuSettings}
|
||||
onClick={() => toggleSidebarView("system")}
|
||||
/>
|
||||
</div>
|
||||
<div className="items-center hidden gap-x-2 lg:flex">
|
||||
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Fullscreen"
|
||||
LeadingIcon={LuMaximize}
|
||||
onClick={() => requestFullscreen()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
99
ui/src/components/AuthLayout.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
import { GoogleIcon } from "@components/Icons";
|
||||
import SimpleNavbar from "@components/SimpleNavbar";
|
||||
import Container from "@components/Container";
|
||||
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import StepCounter from "@components/StepCounter";
|
||||
|
||||
type AuthLayoutProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
action: string;
|
||||
cta: string;
|
||||
ctaHref: string;
|
||||
showCounter?: boolean;
|
||||
};
|
||||
|
||||
export default function AuthLayout({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
cta,
|
||||
ctaHref,
|
||||
showCounter,
|
||||
}: AuthLayoutProps) {
|
||||
const [sq] = useSearchParams();
|
||||
const location = useLocation();
|
||||
|
||||
const returnTo = sq.get("returnTo") || location.state?.returnTo;
|
||||
const deviceId = sq.get("deviceId") || location.state?.deviceId;
|
||||
const navigation = useNavigation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<SimpleNavbar
|
||||
logoHref="/"
|
||||
actionElement={
|
||||
<div>
|
||||
<LinkButton to={ctaHref} text={cta} theme="light" size="MD" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-2xl -mt-16 space-y-8">
|
||||
{showCounter ? (
|
||||
<div className="text-center">
|
||||
<StepCounter currStepIdx={0} nSteps={2} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">{description}</p>
|
||||
</div>
|
||||
|
||||
<Fieldset className="space-y-12">
|
||||
<div className="max-w-sm mx-auto space-y-4">
|
||||
<form
|
||||
action={`${import.meta.env.VITE_CLOUD_API}/oidc/google`}
|
||||
method="POST"
|
||||
>
|
||||
{/*This could be the KVM ID*/}
|
||||
{deviceId ? (
|
||||
<input type="hidden" name="deviceId" value={deviceId} />
|
||||
) : null}
|
||||
{returnTo ? (
|
||||
<input type="hidden" name="returnTo" value={returnTo} />
|
||||
) : null}
|
||||
<Button
|
||||
size="LG"
|
||||
theme="light"
|
||||
fullWidth
|
||||
text={`${action}`}
|
||||
LeadingIcon={GoogleIcon}
|
||||
textAlign="center"
|
||||
type="submit"
|
||||
loading={
|
||||
(navigation.state === "submitting" ||
|
||||
navigation.state === "loading") &&
|
||||
navigation.formMethod?.toLowerCase() === "post" &&
|
||||
navigation.formAction?.includes("auth/google")
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
ui/src/components/AutoHeight.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import AnimateHeight, { Height } from "react-animate-height";
|
||||
|
||||
const AutoHeight = ({ children, ...props }: { children: React.ReactNode }) => {
|
||||
const [height, setHeight] = useState<Height>("auto");
|
||||
const contentDiv = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = contentDiv.current as HTMLDivElement;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
setHeight(element.clientHeight);
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimateHeight
|
||||
{...props}
|
||||
height={height}
|
||||
duration={300}
|
||||
contentClassName="auto-content pointer-events-none"
|
||||
contentRef={contentDiv}
|
||||
disableDisplayNone
|
||||
>
|
||||
{children}
|
||||
</AnimateHeight>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoHeight;
|
||||
245
ui/src/components/Button.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import React from "react";
|
||||
import ExtLink from "@/components/ExtLink";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import { cva, cx } from "@/cva.config";
|
||||
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
|
||||
|
||||
const sizes = {
|
||||
XS: "h-[28px] 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: cx(
|
||||
// Base styles
|
||||
"bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow",
|
||||
// Hover states
|
||||
"group-hover:bg-blue-800",
|
||||
// Active states
|
||||
"group-active:bg-blue-900",
|
||||
),
|
||||
danger: cx(
|
||||
// Base styles
|
||||
"bg-red-600 text-white border-red-700 shadow-sm shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20",
|
||||
// Hover states
|
||||
"group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600",
|
||||
// Active states
|
||||
"group-active:bg-red-800 dark:group-active:bg-red-800",
|
||||
// Focus states
|
||||
"group-focus:ring-red-700 dark:group-focus:ring-red-600",
|
||||
),
|
||||
light: cx(
|
||||
// Base styles
|
||||
"bg-white text-black border-slate-800/30 shadow dark:bg-slate-800 dark:border-slate-300/20 dark:text-white",
|
||||
// Hover states
|
||||
"group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700",
|
||||
// Active states
|
||||
"group-active:bg-blue-100/60 dark:group-active:bg-slate-600",
|
||||
// Disabled states
|
||||
"group-disabled:group-hover:bg-white dark:group-disabled:group-hover:bg-slate-800",
|
||||
),
|
||||
lightDanger: cx(
|
||||
// Base styles
|
||||
"bg-white text-black border-red-400/60 shadow-sm",
|
||||
// Hover states
|
||||
"group-hover:bg-red-50/80",
|
||||
// Active states
|
||||
"group-active:bg-red-100/60",
|
||||
// Focus states
|
||||
"group-focus:ring-red-700",
|
||||
),
|
||||
blank: cx(
|
||||
// Base styles
|
||||
"bg-white/0 text-black border-transparent dark:text-white",
|
||||
// Hover states
|
||||
"group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600",
|
||||
// Active states
|
||||
"group-active:bg-slate-100/80",
|
||||
),
|
||||
};
|
||||
|
||||
const btnVariants = cva({
|
||||
base: cx(
|
||||
// Base styles
|
||||
"border rounded select-none",
|
||||
// Size classes
|
||||
"justify-center items-center shrink-0",
|
||||
// Transition classes
|
||||
"outline-none transition-all duration-200",
|
||||
// Text classes
|
||||
"font-display text-center font-medium leading-tight",
|
||||
// States
|
||||
"group-focus:outline-none group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700",
|
||||
"group-disabled:opacity-50 group-disabled:pointer-events-none",
|
||||
),
|
||||
|
||||
variants: {
|
||||
size: sizes,
|
||||
theme: themes,
|
||||
},
|
||||
});
|
||||
|
||||
const iconVariants = cva({
|
||||
variants: {
|
||||
size: {
|
||||
XS: "h-3.5",
|
||||
SM: "h-3.5",
|
||||
MD: "h-5",
|
||||
LG: "h-6",
|
||||
XL: "h-6",
|
||||
},
|
||||
theme: {
|
||||
primary: "text-white",
|
||||
danger: "text-white ",
|
||||
light: "text-black dark:text-white",
|
||||
lightDanger: "text-black dark:text-white",
|
||||
blank: "text-black dark:text-white",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type ButtonContentPropsType = {
|
||||
text?: string | React.ReactNode;
|
||||
LeadingIcon?: React.FC<{ className: string | undefined }> | null;
|
||||
TrailingIcon?: React.FC<{ className: string | undefined }> | null;
|
||||
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?: FetcherWithComponents<unknown>;
|
||||
};
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
|
||||
const classes = cx(
|
||||
"group outline-none",
|
||||
props.fullWidth ? "w-full" : "",
|
||||
loading ? "pointer-events-none" : "",
|
||||
);
|
||||
const navigation = useNavigation();
|
||||
const loader = fetcher ? fetcher : navigation;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
type LinkPropsType = Pick<LinkProps, "to"> &
|
||||
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
|
||||
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
|
||||
const classes = 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>
|
||||
);
|
||||
};
|
||||
38
ui/src/components/Card.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
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="absolute inset-0 z-0 w-full h-full transition-colors duration-300 ease-in-out bg-gradient-to-tr from-blue-50/30 to-blue-50/20 dark:from-slate-800/30 dark:to-slate-800/20" />
|
||||
<div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" />
|
||||
<div className="h-full isolate">{children}</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Card({ children, className }: CardPropsType) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"w-full rounded border-none dark:bg-slate-800 dark:outline-slate-300/20 bg-white shadow outline outline-1 outline-slate-800/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
ui/src/components/CardHeader.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
headline: string;
|
||||
description?: string | React.ReactNode;
|
||||
Button?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const CardHeader = ({ headline, description, Button }: Props) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between pb-0 gap-x-4">
|
||||
<div className="space-y-1 grow">
|
||||
<h3 className="text-lg font-bold leading-none text-black dark:text-white">{headline}</h3>
|
||||
{description && <div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>}
|
||||
</div>
|
||||
{Button && <div>{Button}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
77
ui/src/components/Checkbox.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Ref } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
import clsx from "clsx";
|
||||
import { cva, cx } from "@/cva.config";
|
||||
|
||||
const sizes = {
|
||||
SM: "w-4 h-4",
|
||||
MD: "w-5 h-5",
|
||||
};
|
||||
|
||||
const checkboxVariants = cva({
|
||||
base: cx(
|
||||
"block rounded",
|
||||
|
||||
// Colors
|
||||
"border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 text-blue-700 dark:text-blue-500 transition-colors",
|
||||
|
||||
// Hover
|
||||
"hover:bg-slate-200/50 dark:hover:bg-slate-700/50",
|
||||
|
||||
// Active
|
||||
"active:bg-slate-200 dark:active:bg-slate-700",
|
||||
|
||||
// Focus
|
||||
"focus:border-slate-300 dark:focus:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-700 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900",
|
||||
|
||||
// Disabled
|
||||
"disabled:pointer-events-none disabled:opacity-30",
|
||||
),
|
||||
variants: { size: sizes },
|
||||
});
|
||||
|
||||
type CheckBoxProps = {
|
||||
size?: keyof typeof sizes;
|
||||
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
|
||||
|
||||
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
|
||||
{ size = "MD", ...props },
|
||||
ref,
|
||||
) {
|
||||
const classes = checkboxVariants({ size });
|
||||
return <input ref={ref} {...props} type="checkbox" className={classes} />;
|
||||
});
|
||||
Checkbox.displayName = "Checkbox";
|
||||
|
||||
type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
|
||||
CheckBoxProps & {
|
||||
fullWidth?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
|
||||
function CheckboxWithLabel(
|
||||
{ label, id, description, as, fullWidth, readOnly, ...props },
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"flex shrink-0 items-center justify-between gap-x-2",
|
||||
fullWidth ? "flex" : "inline-flex",
|
||||
readOnly ? "pointer-events-none opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
<Checkbox ref={ref as never} {...props} />
|
||||
<div className="max-w-md">
|
||||
<FieldLabel label={label} id={id} description={description} as="span" />
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
CheckboxWithLabel.displayName = "CheckboxWithLabel";
|
||||
|
||||
export default Checkbox;
|
||||
export { CheckboxWithLabel, Checkbox };
|
||||
20
ui/src/components/Container.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { cx } from "@/cva.config";
|
||||
|
||||
function Container({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cx("mx-auto h-full w-full px-8 ", 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,
|
||||
});
|
||||
32
ui/src/components/CustomTooltip.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import Card from "@components/Card";
|
||||
|
||||
export type CustomTooltipProps = {
|
||||
payload: { payload: { date: number; stat: number }; unit: string }[];
|
||||
};
|
||||
|
||||
export default function CustomTooltip({ payload }: CustomTooltipProps) {
|
||||
if (payload?.length) {
|
||||
const toolTipData = payload[0];
|
||||
const { date, stat } = toolTipData.payload;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-2 text-black dark:text-white">
|
||||
<div className="font-semibold">
|
||||
{new Date(date * 1000).toLocaleTimeString()}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="h-[2px] w-2 bg-blue-700" />
|
||||
<span >
|
||||
{stat} {toolTipData?.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
39
ui/src/components/EmptyCard.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { GridCard } from "@/components/Card";
|
||||
import React from "react";
|
||||
import { cx } from "../cva.config";
|
||||
|
||||
type Props = {
|
||||
IconElm?: React.FC<any>;
|
||||
headline: string;
|
||||
description?: string | React.ReactNode;
|
||||
BtnElm?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function EmptyCard({
|
||||
IconElm,
|
||||
headline,
|
||||
description,
|
||||
BtnElm,
|
||||
className,
|
||||
}: Props) {
|
||||
return (
|
||||
<GridCard>
|
||||
<div
|
||||
className={cx(
|
||||
"flex min-h-[256px] w-full flex-col items-center justify-center gap-y-4 px-4 py-5 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
|
||||
<div className="space-y-2">
|
||||
{IconElm && <IconElm className="w-6 h-6 mx-auto text-blue-600 dark:text-blue-400" />}
|
||||
<h4 className="text-base font-bold leading-none text-black dark:text-white">{headline}</h4>
|
||||
</div>
|
||||
<p className="mx-auto text-sm text-slate-600 dark:text-slate-400">{description}</p>
|
||||
</div>
|
||||
{BtnElm}
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
28
ui/src/components/ExtLink.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { cx } from "@/cva.config";
|
||||
|
||||
export default function ExtLink({
|
||||
className,
|
||||
href,
|
||||
id,
|
||||
target,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
href: string;
|
||||
id?: string;
|
||||
target?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
className={cx(className)}
|
||||
target={target ?? "_blank"}
|
||||
id={id}
|
||||
rel="noopener noreferrer"
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
51
ui/src/components/FieldLabel.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import { cx } from "@/cva.config";
|
||||
|
||||
type Props = {
|
||||
label: string | React.ReactNode;
|
||||
id?: string;
|
||||
as?: "label" | "span";
|
||||
description?: string | React.ReactNode | null;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export default function FieldLabel({
|
||||
label,
|
||||
id,
|
||||
as = "label",
|
||||
description,
|
||||
disabled,
|
||||
}: Props) {
|
||||
if (as === "label") {
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cx(
|
||||
"flex select-none flex-col text-left font-display text-[13px] font-semibold leading-snug text-black dark:text-white",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{description && (
|
||||
<span className="my-0.5 text-[13px] font-normal text-slate-600">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
} else if (as === "span") {
|
||||
return (
|
||||
<div className="flex flex-col select-none">
|
||||
<span className="font-display text-[13px] font-medium leading-snug text-black">
|
||||
{label}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="my-0.5 text-[13px] font-normal text-slate-600">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
30
ui/src/components/Fieldset.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { FetcherWithComponents, useNavigation } from "react-router-dom";
|
||||
|
||||
export default function Fieldset({
|
||||
children,
|
||||
fetcher,
|
||||
className,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
fetcher?: FetcherWithComponents<any>;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
const loader = fetcher ? fetcher : navigation;
|
||||
return (
|
||||
<fieldset
|
||||
className={clsx(className)}
|
||||
disabled={
|
||||
disabled ??
|
||||
((loader.state === "submitting" || loader.state === "loading") &&
|
||||
loader.formMethod?.toLowerCase() === "post")
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
41
ui/src/components/GridBackground.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
export default function GridBackground() {
|
||||
return (
|
||||
<div className="absolute w-screen h-screen overflow-hidden isolate opacity-60">
|
||||
<svg
|
||||
className="absolute inset-x-0 top-0 -z-10 h-[64rem] w-full stroke-gray-300 [mask-image:radial-gradient(32rem_32rem_at_center,white,transparent)] dark:stroke-slate-300/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84"
|
||||
width={200}
|
||||
height={200}
|
||||
x="50%"
|
||||
y={-1}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M.5 200V.5H200" fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<svg
|
||||
x="50%"
|
||||
y={-1}
|
||||
className="overflow-visible fill-blue-100 dark:fill-blue-900/30"
|
||||
>
|
||||
<path
|
||||
d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
strokeWidth={0}
|
||||
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
ui/src/components/Header.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Fragment, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||
import { Menu, MenuButton, Transition } from "@headlessui/react";
|
||||
import Container from "@/components/Container";
|
||||
import Card from "@/components/Card";
|
||||
import { LuMonitorSmartphone } from "react-icons/lu";
|
||||
import { cx } from "@/cva.config";
|
||||
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import USBStateStatus from "@components/USBStateStatus";
|
||||
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
||||
import api from "../api";
|
||||
import { isOnDevice } from "../main";
|
||||
import { Button, LinkButton } from "./Button";
|
||||
|
||||
interface NavbarProps {
|
||||
isLoggedIn: boolean;
|
||||
primaryLinks?: { title: string; to: string }[];
|
||||
userEmail?: string;
|
||||
showConnectionStatus?: boolean;
|
||||
picture?: string;
|
||||
kvmName?: string;
|
||||
}
|
||||
|
||||
export default function DashboardNavbar({
|
||||
primaryLinks = [],
|
||||
isLoggedIn,
|
||||
showConnectionStatus,
|
||||
userEmail,
|
||||
picture,
|
||||
kvmName,
|
||||
}: NavbarProps) {
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
|
||||
const setUser = useUserStore(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
const onLogout = useCallback(async () => {
|
||||
const logoutUrl = isOnDevice
|
||||
? `${import.meta.env.VITE_SIGNAL_API}/auth/logout`
|
||||
: `${import.meta.env.VITE_CLOUD_API}/logout`;
|
||||
const res = await api.POST(logoutUrl);
|
||||
if (!res.ok) return;
|
||||
|
||||
setUser(null);
|
||||
// The root route will redirect to appropiate login page, be it the local one or the cloud one
|
||||
navigate("/");
|
||||
}, [navigate, setUser]);
|
||||
|
||||
const usbState = useHidStore(state => state.usbState);
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white border-b select-none border-b-slate-800/20 dark:border-b-slate-300/20 dark:bg-slate-900">
|
||||
<Container>
|
||||
<div className="flex items-center justify-between h-14">
|
||||
<div className="flex items-center shrink-0 gap-x-8">
|
||||
<div className="inline-block shrink-0">
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
{primaryLinks.map(({ title, to }, i) => {
|
||||
return (
|
||||
<LinkButton
|
||||
key={i + title}
|
||||
theme="blank"
|
||||
size="SM"
|
||||
text={title}
|
||||
to={to}
|
||||
LeadingIcon={LuMonitorSmartphone}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end w-full gap-x-2">
|
||||
<div className="flex items-center space-x-4 shrink-0">
|
||||
{showConnectionStatus && (
|
||||
<div className="items-center hidden gap-x-2 md:flex">
|
||||
<div className="w-[159px]">
|
||||
<PeerConnectionStatusCard
|
||||
state={peerConnectionState}
|
||||
title={kvmName}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-[159px] md:block">
|
||||
<USBStateStatus
|
||||
state={usbState}
|
||||
peerConnectionState={peerConnectionState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<hr className="h-[20px] w-[1px] border-none bg-slate-800/20 dark:bg-slate-300/20" />
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton as={Fragment}>
|
||||
<Button
|
||||
theme="blank"
|
||||
size="SM"
|
||||
text={
|
||||
<>
|
||||
{picture ? <></> : userEmail}
|
||||
<ChevronDownIcon className="w-4 h-4 shrink-0 text-slate-900 dark:text-white" />
|
||||
</>
|
||||
}
|
||||
LeadingIcon={({ className }) => (
|
||||
picture && (
|
||||
<img
|
||||
src={picture}
|
||||
alt="Avatar"
|
||||
className={cx(
|
||||
className,
|
||||
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
/>
|
||||
</MenuButton>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-in-out duration-75"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition ease-in-out duration-75"
|
||||
leaveFrom="transform opacity-75"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-1 space-y-1 dark:text-white">
|
||||
{userEmail && (
|
||||
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
|
||||
<Menu.Item>
|
||||
<div className="p-2">
|
||||
<div className="text-xs font-display">
|
||||
Logged in as
|
||||
</div>
|
||||
<div className="w-[200px] truncate font-display text-sm font-semibold">
|
||||
{userEmail}
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Menu.Item>
|
||||
<div onClick={onLogout}>
|
||||
<button className="block w-full">
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-600 dark:hover:bg-slate-700">
|
||||
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" />
|
||||
<div className="font-display">Log out</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
328
ui/src/components/Icons.tsx
Normal file
142
ui/src/components/InfoBar.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { cx } from "@/cva.config";
|
||||
import {
|
||||
useHidStore,
|
||||
useMouseStore,
|
||||
useRTCStore,
|
||||
useSettingsStore,
|
||||
useVideoStore,
|
||||
} from "@/hooks/stores";
|
||||
import { useEffect } from "react";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
|
||||
export default function InfoBar() {
|
||||
const activeKeys = useHidStore(state => state.activeKeys);
|
||||
const activeModifiers = useHidStore(state => state.activeModifiers);
|
||||
const mouseX = useMouseStore(state => state.mouseX);
|
||||
const mouseY = useMouseStore(state => state.mouseY);
|
||||
|
||||
const videoClientSize = useVideoStore(
|
||||
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
||||
);
|
||||
|
||||
const videoSize = useVideoStore(
|
||||
state => `${Math.round(state.width)}x${Math.round(state.height)}`,
|
||||
);
|
||||
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!rpcDataChannel) return;
|
||||
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
|
||||
rpcDataChannel.onerror = e =>
|
||||
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
|
||||
}, [rpcDataChannel]);
|
||||
|
||||
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
|
||||
const isNumLockActive = useHidStore(state => state.isNumLockActive);
|
||||
const isScrollLockActive = useHidStore(state => state.isScrollLockActive);
|
||||
|
||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
||||
|
||||
const usbState = useHidStore(state => state.usbState);
|
||||
const hdmiState = useVideoStore(state => state.hdmiState);
|
||||
|
||||
return (
|
||||
<div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300">
|
||||
<div className="flex flex-wrap items-stretch justify-between gap-1">
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-wrap items-center pl-2 gap-x-4">
|
||||
{settings.debugMode ? (
|
||||
<div className="flex">
|
||||
<span className="text-xs font-semibold">Resolution:</span>{" "}
|
||||
<span className="text-xs">{videoSize}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settings.debugMode ? (
|
||||
<div className="flex">
|
||||
<span className="text-xs font-semibold">Video Size: </span>
|
||||
<span className="text-xs">{videoClientSize}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settings.debugMode ? (
|
||||
<div className="flex w-[118px] items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">Pointer:</span>
|
||||
<span className="text-xs">
|
||||
{mouseX},{mouseY}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settings.debugMode && (
|
||||
<div className="flex w-[156px] items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">USB State:</span>
|
||||
<span className="text-xs">{usbState}</span>
|
||||
</div>
|
||||
)}
|
||||
{settings.debugMode && (
|
||||
<div className="flex w-[156px] items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">HDMI State:</span>
|
||||
<span className="text-xs">{hdmiState}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">Keys:</span>
|
||||
<h2 className="text-xs">
|
||||
{[
|
||||
...activeKeys.map(
|
||||
x => Object.entries(keys).filter(y => y[1] === x)[0][0],
|
||||
),
|
||||
activeModifiers.map(
|
||||
x => Object.entries(modifiers).filter(y => y[1] === x)[0][0],
|
||||
),
|
||||
].join(", ")}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20">
|
||||
{isTurnServerInUse && (
|
||||
<div className="shrink-0 p-1 px-1.5 text-xs text-black dark:text-white">
|
||||
Relayed by Cloudflare
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cx(
|
||||
"shrink-0 p-1 px-1.5 text-xs",
|
||||
isCapsLockActive
|
||||
? "text-black dark:text-white"
|
||||
: "text-slate-800/20 dark:text-slate-300/20",
|
||||
)}
|
||||
>
|
||||
Caps Lock
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
"shrink-0 p-1 px-1.5 text-xs",
|
||||
isNumLockActive
|
||||
? "text-black dark:text-white"
|
||||
: "text-slate-800/20 dark:text-slate-300/20",
|
||||
)}
|
||||
>
|
||||
Num Lock
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
"shrink-0 p-1 px-1.5 text-xs",
|
||||
isScrollLockActive
|
||||
? "text-black dark:text-white"
|
||||
: "text-slate-800/20 dark:text-slate-300/20",
|
||||
)}
|
||||
>
|
||||
Scroll Lock
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
ui/src/components/InputField.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Ref } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
import clsx from "clsx";
|
||||
import Card from "@/components/Card";
|
||||
import { cva } from "@/cva.config";
|
||||
|
||||
const sizes = {
|
||||
XS: "h-[26px] px-3 text-xs",
|
||||
SM: "h-[36px] px-3 text-[14px]",
|
||||
MD: "h-[40px] px-4 text-sm",
|
||||
LG: "h-[48px] py-4 px-5 text-base",
|
||||
};
|
||||
|
||||
const inputVariants = cva({
|
||||
variants: { size: sizes },
|
||||
});
|
||||
|
||||
type InputFieldProps = {
|
||||
size?: keyof typeof sizes;
|
||||
TrailingElm?: React.ReactNode;
|
||||
LeadingElm?: React.ReactNode;
|
||||
error?: string | null;
|
||||
} & Omit<JSX.IntrinsicElements["input"], "size">;
|
||||
|
||||
type InputFieldWithLabelProps = InputFieldProps & {
|
||||
label: React.ReactNode;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(
|
||||
{ LeadingElm, TrailingElm, className, size = "MD", error, ...props },
|
||||
ref,
|
||||
) {
|
||||
const sizeClasses = inputVariants({ size });
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={clsx(
|
||||
// General styling
|
||||
"relative flex w-full overflow-hidden",
|
||||
|
||||
"[&:has(:user-invalid)]:ring-2 [&:has(:user-invalid)]:ring-red-600 [&:has(:user-invalid)]:ring-offset-2",
|
||||
|
||||
// Focus Within
|
||||
"focus-within:border-slate-300 dark:focus-within:border-slate-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-700 focus-within:ring-offset-2",
|
||||
|
||||
// Disabled Within
|
||||
"disabled-within:pointer-events-none disabled-within:select-none disabled-within:bg-slate-50 dark:disabled-within:bg-slate-800 disabled-within:text-slate-500/80",
|
||||
)}
|
||||
>
|
||||
{LeadingElm && (
|
||||
<div className={clsx("pointer-events-none border-r border-r-slate-300 dark:border-r-slate-600")}>
|
||||
{LeadingElm}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
sizeClasses,
|
||||
TrailingElm ? "pr-2" : "",
|
||||
className,
|
||||
"block flex-1 border-0 bg-transparent leading-none placeholder:text-sm placeholder:text-slate-300 dark:placeholder:text-slate-500 focus:ring-0 text-black dark:text-white",
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{TrailingElm && (
|
||||
<div className="flex items-center pr-3 pointer-events-none">{TrailingElm}</div>
|
||||
)}
|
||||
</Card>
|
||||
{error && <FieldError error={error} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
InputField.displayName = "InputField";
|
||||
|
||||
const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProps>(
|
||||
function InputFieldWithLabel(
|
||||
{ label, description, id, ...props },
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) {
|
||||
return (
|
||||
<div className="w-full space-y-1">
|
||||
{(label || description) && (
|
||||
<FieldLabel label={label} id={id} description={description} />
|
||||
)}
|
||||
<InputField ref={ref as any} id={id} {...props} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
InputFieldWithLabel.displayName = "InputFieldWithLabel";
|
||||
|
||||
export default InputField;
|
||||
export { InputFieldWithLabel };
|
||||
|
||||
export function FieldError({ error }: { error: string | React.ReactNode }) {
|
||||
return <div className="mt-[6px] text-[13px] leading-normal text-red-500">{error}</div>;
|
||||
}
|
||||
153
ui/src/components/KvmCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { MdConnectWithoutContact } from "react-icons/md";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuEllipsisVertical } from "react-icons/lu";
|
||||
|
||||
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
|
||||
// Allow dates or times to be passed
|
||||
const timeMs = typeof date === "number" ? date : date.getTime();
|
||||
|
||||
// Get the amount of seconds between the given date and now
|
||||
const deltaSeconds = Math.round((timeMs - Date.now()) / 1000);
|
||||
|
||||
// Array reprsenting one minute, hour, day, week, month, etc in seconds
|
||||
const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
|
||||
|
||||
// Array equivalent to the above but in the string representation of the units
|
||||
const units: Intl.RelativeTimeFormatUnit[] = [
|
||||
"second",
|
||||
"minute",
|
||||
"hour",
|
||||
"day",
|
||||
"week",
|
||||
"month",
|
||||
"year",
|
||||
];
|
||||
|
||||
// Grab the ideal cutoff unit
|
||||
const unitIndex = cutoffs.findIndex(cutoff => cutoff > Math.abs(deltaSeconds));
|
||||
|
||||
// Get the divisor to divide from the seconds. E.g. if our unit is "day" our divisor
|
||||
// is one day in seconds, so we can divide our seconds by this to get the # of days
|
||||
const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1;
|
||||
|
||||
// Intl.RelativeTimeFormat do its magic
|
||||
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: "auto" });
|
||||
return rtf.format(Math.floor(deltaSeconds / divisor), units[unitIndex]);
|
||||
}
|
||||
|
||||
export default function KvmCard({
|
||||
title,
|
||||
id,
|
||||
online,
|
||||
lastSeen,
|
||||
}: {
|
||||
title: string;
|
||||
id: string;
|
||||
online: boolean;
|
||||
lastSeen: Date | null;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="px-5 py-5 space-y-3">
|
||||
<div className="flex justify-between items-cente">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-lg font-bold leading-none text-black dark:text-white">
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{online ? (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full border border-green-600 bg-green-500" />
|
||||
<div className="text-sm text-black dark:text-white">Online</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full border border-slate-400/60 dark:border-slate-500 bg-slate-200 dark:bg-slate-600" />
|
||||
<div className="text-sm text-black dark:text-white">
|
||||
{lastSeen ? (
|
||||
<>Last online {getRelativeTimeString(lastSeen)}</>
|
||||
) : (
|
||||
<>Never seen online</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[1px] bg-slate-800/20 dark:bg-slate-300/20" />
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
{online ? (
|
||||
<LinkButton
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="Connect to KVM"
|
||||
LeadingIcon={MdConnectWithoutContact}
|
||||
textAlign="center"
|
||||
to={`/devices/${id}`}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="Troubleshoot Connection"
|
||||
textAlign="center"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
theme="light"
|
||||
TrailingIcon={LuEllipsisVertical}
|
||||
size="MD"
|
||||
></MenuButton>
|
||||
</div>
|
||||
|
||||
<MenuItems
|
||||
transition
|
||||
className="data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
>
|
||||
<Card className="absolute right-0 z-10 w-56 px-1 mt-2 transition origin-top-right ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="divide-y divide-slate-800/20 dark:divide-slate-300/20">
|
||||
<MenuItem>
|
||||
<div>
|
||||
<div className="block w-full">
|
||||
<div className="flex items-center px-2 my-1 text-sm transition-colors rounded-md gap-x-2 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<Link
|
||||
className="block w-full py-1.5 text-black dark:text-white"
|
||||
to={`./${id}/rename`}
|
||||
>
|
||||
Rename
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div>
|
||||
<div className="block w-full">
|
||||
<div className="flex items-center px-2 my-1 text-sm transition-colors rounded-md gap-x-2 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<Link
|
||||
className="block w-full py-1.5 text-black dark:text-white"
|
||||
to={`./${id}/deregister`}
|
||||
>
|
||||
Deregister from cloud
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Card>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
30
ui/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
356
ui/src/components/LocalAuthPasswordDialog.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { GridCard } from "@/components/Card";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import Modal from "@components/Modal";
|
||||
import { InputFieldWithLabel } from "./InputField";
|
||||
import api from "@/api";
|
||||
import { useLocalAuthModalStore } from "@/hooks/stores";
|
||||
|
||||
export default function LocalAuthPasswordDialog({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={() => setOpen(false)}>
|
||||
<Dialog setOpen={setOpen} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||
const { modalView, setModalView } = useLocalAuthModalStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCreatePassword = async (password: string, confirmPassword: string) => {
|
||||
if (password === "") {
|
||||
setError("Please enter a password");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.POST("/auth/password-local", { password });
|
||||
if (res.ok) {
|
||||
setModalView("creationSuccess");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "An error occurred while setting the password");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("An error occurred while setting the password");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePassword = async (
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
confirmNewPassword: string,
|
||||
) => {
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldPassword === "") {
|
||||
setError("Please enter your old password");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword === "") {
|
||||
setError("Please enter a new password");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.PUT("/auth/password-local", {
|
||||
oldPassword,
|
||||
newPassword,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setModalView("updateSuccess");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "An error occurred while changing the password");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("An error occurred while changing the password");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePassword = async (password: string) => {
|
||||
if (password === "") {
|
||||
setError("Please enter your current password");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.DELETE("/auth/local-password", { password });
|
||||
if (res.ok) {
|
||||
setModalView("deleteSuccess");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "An error occurred while disabling the password");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("An error occurred while disabling the password");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
|
||||
<div className="p-10">
|
||||
{modalView === "createPassword" && (
|
||||
<CreatePasswordModal
|
||||
onSetPassword={handleCreatePassword}
|
||||
onCancel={() => setOpen(false)}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "deletePassword" && (
|
||||
<DeletePasswordModal
|
||||
onDeletePassword={handleDeletePassword}
|
||||
onCancel={() => setOpen(false)}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updatePassword" && (
|
||||
<UpdatePasswordModal
|
||||
onUpdatePassword={handleUpdatePassword}
|
||||
onCancel={() => setOpen(false)}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "creationSuccess" && (
|
||||
<SuccessModal
|
||||
headline="Password Set Successfully"
|
||||
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "deleteSuccess" && (
|
||||
<SuccessModal
|
||||
headline="Password Protection Disabled"
|
||||
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updateSuccess" && (
|
||||
<SuccessModal
|
||||
headline="Password Updated Successfully"
|
||||
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
|
||||
function CreatePasswordModal({
|
||||
onSetPassword,
|
||||
onCancel,
|
||||
error,
|
||||
}: {
|
||||
onSetPassword: (password: string, confirmPassword: string) => void;
|
||||
onCancel: () => void;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">Local Device Protection</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Create a password to protect your device from unauthorized local access.
|
||||
</p>
|
||||
</div>
|
||||
<InputFieldWithLabel
|
||||
label="New Password"
|
||||
type="password"
|
||||
placeholder="Enter a strong password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
placeholder="Re-enter your password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Secure Device"
|
||||
onClick={() => onSetPassword(password, confirmPassword)}
|
||||
/>
|
||||
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletePasswordModal({
|
||||
onDeletePassword,
|
||||
onCancel,
|
||||
error,
|
||||
}: {
|
||||
onDeletePassword: (password: string) => void;
|
||||
onCancel: () => void;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">Disable Local Device Protection</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Enter your current password to disable local device protection.
|
||||
</p>
|
||||
</div>
|
||||
<InputFieldWithLabel
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter your current password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="danger"
|
||||
text="Disable Protection"
|
||||
onClick={() => onDeletePassword(password)}
|
||||
/>
|
||||
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdatePasswordModal({
|
||||
onUpdatePassword,
|
||||
onCancel,
|
||||
error,
|
||||
}: {
|
||||
onUpdatePassword: (
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
confirmNewPassword: string,
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">Change Local Device Password</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Enter your current password and a new password to update your local device
|
||||
protection.
|
||||
</p>
|
||||
</div>
|
||||
<InputFieldWithLabel
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter your current password"
|
||||
value={oldPassword}
|
||||
onChange={e => setOldPassword(e.target.value)}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
label="New Password"
|
||||
type="password"
|
||||
placeholder="Enter a new strong password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
placeholder="Re-enter your new password"
|
||||
value={confirmNewPassword}
|
||||
onChange={e => setConfirmNewPassword(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Update Password"
|
||||
onClick={() => onUpdatePassword(oldPassword, newPassword, confirmNewPassword)}
|
||||
/>
|
||||
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessModal({
|
||||
headline,
|
||||
description,
|
||||
onClose,
|
||||
}: {
|
||||
headline: string;
|
||||
description: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
|
||||
</div>
|
||||
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
ui/src/components/Modal.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
|
||||
import { cx } from "@/cva.config";
|
||||
|
||||
export default function Modal({
|
||||
children,
|
||||
className,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="relative z-10">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-500/75 dark:bg-slate-900/90 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
|
||||
<DialogPanel
|
||||
transition
|
||||
className={cx(
|
||||
"pointer-events-none relative w-full sm:my-8",
|
||||
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-300 data-[enter]:ease-out data-[leave]:ease-in",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="inline-block w-full text-left pointer-events-auto">
|
||||
<div className="flex justify-center" onClick={onClose}>
|
||||
<div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1578
ui/src/components/MountMediaDialog.tsx
Normal file
18
ui/src/components/NotFoundPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import EmptyCard from "@/components/EmptyCard";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
52
ui/src/components/OtherSessionConnectedModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { GridCard } from "@/components/Card";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlue from "@/assets/logo-blue.svg";
|
||||
import LogoWhite from "@/assets/logo-white.svg";
|
||||
import Modal from "@components/Modal";
|
||||
|
||||
export default function OtherSessionConnectedModal({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={() => setOpen(false)}>
|
||||
<Dialog setOpen={setOpen} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||
return (
|
||||
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
|
||||
<div className="p-10">
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="h-[24px]">
|
||||
<img src={LogoBlue} alt="" className="h-full dark:hidden" />
|
||||
<img src={LogoWhite} alt="" className="hidden h-full dark:block" />
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold dark:text-white">
|
||||
Another Active Session Detected
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
Only one active session is supported at a time. Would you like to take over
|
||||
this session?
|
||||
</p>
|
||||
<div className="flex items-center justify-start space-x-4">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Use Here"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
66
ui/src/components/PeerConnectionStatusCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import StatusCard from "@components/StatusCards";
|
||||
|
||||
const PeerConnectionStatusMap = {
|
||||
connected: "Connected",
|
||||
connecting: "Connecting",
|
||||
disconnected: "Disconnected",
|
||||
error: "Connection error",
|
||||
closing: "Closing",
|
||||
failed: "Connection failed",
|
||||
closed: "Closed",
|
||||
new: "Connecting",
|
||||
};
|
||||
|
||||
export type PeerConnections = keyof typeof PeerConnectionStatusMap;
|
||||
|
||||
type StatusProps = {
|
||||
[key in PeerConnections]: {
|
||||
statusIndicatorClassName: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function PeerConnectionStatusCard({
|
||||
state,
|
||||
title,
|
||||
}: {
|
||||
state?: PeerConnections;
|
||||
title?: string;
|
||||
}) {
|
||||
if (!state) return null;
|
||||
const StatusCardProps: StatusProps = {
|
||||
connected: {
|
||||
statusIndicatorClassName: "bg-green-500 border-green-600",
|
||||
},
|
||||
connecting: {
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
disconnected: {
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
error: {
|
||||
statusIndicatorClassName: "bg-red-500 border-red-600",
|
||||
},
|
||||
closing: {
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
failed: {
|
||||
statusIndicatorClassName: "bg-red-500 border-red-600",
|
||||
},
|
||||
closed: {
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
["new"]: {
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
};
|
||||
const props = StatusCardProps[state];
|
||||
if (!props) return;
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
title={title || "JetKVM Device"}
|
||||
status={PeerConnectionStatusMap[state]}
|
||||
{...StatusCardProps[state]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
ui/src/components/SectionHeader.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string | ReactNode;
|
||||
description: string | ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
|
||||
<div className="text-sm text-black dark:text-slate-300">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
ui/src/components/SelectMenuBasic.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from "react";
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
import clsx from "clsx";
|
||||
import Card from "./Card";
|
||||
import { cva } from "@/cva.config";
|
||||
|
||||
type SelectMenuProps = Pick<
|
||||
JSX.IntrinsicElements["select"],
|
||||
"disabled" | "onChange" | "name" | "value"
|
||||
> & {
|
||||
defaultSelection?: string;
|
||||
className?: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
size?: keyof typeof sizes;
|
||||
direction?: "vertical" | "horizontal";
|
||||
error?: string;
|
||||
fullWidth?: boolean;
|
||||
} & React.ComponentProps<typeof FieldLabel>;
|
||||
|
||||
const sizes = {
|
||||
XS: "h-[24.5px] pl-3 pr-8 text-xs",
|
||||
SM: "h-[32px] pl-3 pr-8 text-[13px]",
|
||||
MD: "h-[40px] pl-4 pr-10 text-sm",
|
||||
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
|
||||
};
|
||||
|
||||
const selectMenuVariants = cva({
|
||||
variants: { size: sizes },
|
||||
});
|
||||
|
||||
export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuProps>(
|
||||
function SelectMenuBasic(
|
||||
{
|
||||
fullWidth,
|
||||
options,
|
||||
className,
|
||||
direction = "vertical",
|
||||
label,
|
||||
size = "MD",
|
||||
name,
|
||||
disabled,
|
||||
value,
|
||||
id,
|
||||
onChange,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const classes = selectMenuVariants({ size });
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
direction === "vertical" ? "space-y-1" : "flex items-center gap-x-2",
|
||||
fullWidth ? "w-full" : "w-auto",
|
||||
className,
|
||||
"text-sm",
|
||||
)}
|
||||
>
|
||||
{label && <FieldLabel label={label} id={id} as="span" />}
|
||||
<Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0">
|
||||
<select
|
||||
ref={ref}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
classes,
|
||||
|
||||
// General styling
|
||||
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0",
|
||||
|
||||
// Hover
|
||||
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white dark:hover:bg-slate-800/80 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-900",
|
||||
|
||||
// Invalid
|
||||
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
|
||||
|
||||
// Focus
|
||||
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
|
||||
|
||||
// Disabled
|
||||
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
|
||||
|
||||
// Dark mode text
|
||||
"dark:bg-slate-900 dark:text-white"
|
||||
)}
|
||||
value={value}
|
||||
id={id}
|
||||
onChange={onChange}
|
||||
>
|
||||
{options.map(x => (
|
||||
<option key={x.value} value={x.value} disabled={x.disabled}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
52
ui/src/components/SidebarHeader.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Button } from "@components/Button";
|
||||
import { cx } from "@/cva.config";
|
||||
import { AvailableSidebarViews } from "@/hooks/stores";
|
||||
|
||||
export default function SidebarHeader({
|
||||
title,
|
||||
setSidebarView,
|
||||
}: {
|
||||
title: string;
|
||||
setSidebarView: (view: AvailableSidebarViews | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-b-slate-800/20 bg-white px-4 py-1.5 font-semibold text-black dark:bg-slate-900 dark:border-b-slate-300/20">
|
||||
<div className="min-w-0" style={{ flex: 1 }}>
|
||||
<h2 className="text-sm text-black truncate dark:text-white">{title}</h2>
|
||||
</div>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="blank"
|
||||
text="Hide"
|
||||
LeadingIcon={({ className }) => (
|
||||
<svg
|
||||
className={cx(className, "rotate-180")}
|
||||
viewBox="0 0 22 22"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M18 2H4C2.89543 2 2 2.89543 2 4V18C2 19.1046 2.89543 20 4 20H18C19.1046 20 20 19.1046 20 18V4C20 2.89543 19.1046 2 18 2ZM4 0C1.79086 0 0 1.79086 0 4V18C0 20.2091 1.79086 22 4 22H18C20.2091 22 22 20.2091 22 18V4C22 1.79086 20.2091 0 18 0H4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6 21L6 1L8 1L8 21H6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.7803 7.21983C15.9208 7.36045 15.9997 7.55108 15.9997 7.74983C15.9997 7.94858 15.9208 8.1392 15.7803 8.27983L13.0603 10.9998L15.7803 13.7198C15.854 13.7885 15.9131 13.8713 15.9541 13.9633C15.9951 14.0553 16.0171 14.1546 16.0189 14.2553C16.0207 14.356 16.0022 14.456 15.9644 14.5494C15.9267 14.6428 15.8706 14.7276 15.7994 14.7989C15.7281 14.8701 15.6433 14.9262 15.5499 14.9639C15.4565 15.0017 15.3565 15.0202 15.2558 15.0184C15.1551 15.0166 15.0558 14.9946 14.9638 14.9536C14.8718 14.9126 14.789 14.8535 14.7203 14.7798L11.4703 11.5298C11.3299 11.3892 11.251 11.1986 11.251 10.9998C11.251 10.8011 11.3299 10.6105 11.4703 10.4698L14.7203 7.21983C14.8609 7.07938 15.0516 7.00049 15.2503 7.00049C15.4491 7.00049 15.6397 7.07938 15.7803 7.21983Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
onClick={() => setSidebarView(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
ui/src/components/SimpleNavbar.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import Container from "@/components/Container";
|
||||
import { Link } from "react-router-dom";
|
||||
import React from "react";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
|
||||
type Props = { logoHref?: string; actionElement?: React.ReactNode };
|
||||
|
||||
export default function SimpleNavbar({ logoHref, actionElement }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<Container>
|
||||
<div className="pb-4 my-4 border-b border-b-800/20 isolate dark:border-b-slate-300/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to={logoHref ?? "/"} className="hidden h-[26px] dark:inline-block">
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[26px] dark:block hidden" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[26px] dark:hidden" />
|
||||
</Link>
|
||||
<div>{actionElement}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
ui/src/components/StatChart.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Brush,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
|
||||
|
||||
export default function StatChart({
|
||||
data,
|
||||
domain,
|
||||
unit,
|
||||
referenceValue,
|
||||
}: {
|
||||
data: { date: number; stat: number | null | undefined }[];
|
||||
domain?: [string | number, string | number];
|
||||
unit?: string;
|
||||
referenceValue?: number;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 24, right: 8, left: 0, bottom: 0 }}>
|
||||
<Brush startIndex={data.length - 60 * 2} height={1} className="hidden" />
|
||||
<CartesianGrid
|
||||
strokeDasharray={0}
|
||||
vertical={false}
|
||||
strokeLinecap="butt"
|
||||
stroke="rgba(30, 41, 59, 0.1)"
|
||||
/>
|
||||
{referenceValue && (
|
||||
<ReferenceLine
|
||||
y={referenceValue}
|
||||
strokeDasharray="3 3"
|
||||
strokeWidth={1}
|
||||
stroke="rgba(30, 41, 59, 0.3)"
|
||||
/>
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{
|
||||
fontFamily: "Circular",
|
||||
fontSize: "12px",
|
||||
fill: "rgba(107, 114, 128, 1)",
|
||||
}}
|
||||
axisLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
|
||||
tickLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
|
||||
tickFormatter={date => {
|
||||
return new Date(date * 1000).toLocaleString("en-US", {
|
||||
hourCycle: "h23",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}}
|
||||
ticks={data
|
||||
.filter(d => {
|
||||
return d.date % 60 === 0;
|
||||
})
|
||||
.map(x => x.date)}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="stat"
|
||||
axisLine={false}
|
||||
orientation="right"
|
||||
tick={{
|
||||
fontFamily: "Circular",
|
||||
fontSize: "12px",
|
||||
fill: "rgba(107, 114, 128, 1)",
|
||||
}}
|
||||
padding={{ top: 0, bottom: 0 }}
|
||||
tickLine={false}
|
||||
unit={unit}
|
||||
domain={domain || ["auto", "auto"]}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={({ payload }) => {
|
||||
return <CustomTooltip payload={payload as CustomTooltipProps["payload"]} />;
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
dataKey="stat"
|
||||
stroke="rgb(29 78 216)"
|
||||
strokeLinecap="round"
|
||||
strokeWidth={2}
|
||||
unit={unit}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
45
ui/src/components/StatusCards.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { cx } from "@/cva.config";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
status: string;
|
||||
icon?: React.FC<{ className: string | undefined }>;
|
||||
iconClassName?: string;
|
||||
statusIndicatorClassName?: string;
|
||||
}
|
||||
|
||||
export default function StatusCard({
|
||||
title,
|
||||
status,
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
statusIndicatorClassName,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-x-3 rounded-md border bg-white dark:border-slate-600 dark:bg-slate-800 dark:text-white border-slate-800/20 px-2 py-1.5">
|
||||
{Icon ? (
|
||||
<span>
|
||||
<Icon className={cx(iconClassName, "shrink-0")} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold leading-none transition text-ellipsis">
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-xs leading-none">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div
|
||||
className={cx(
|
||||
"h-2 w-2 rounded-full border transition",
|
||||
statusIndicatorClassName,
|
||||
)}
|
||||
/>
|
||||
<span className={cx("transition")}>{status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
ui/src/components/StepCounter.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { CheckIcon } from "@heroicons/react/16/solid";
|
||||
import { cva, cx } from "@/cva.config";
|
||||
import Card from "@/components/Card";
|
||||
|
||||
type Props = {
|
||||
nSteps: number;
|
||||
currStepIdx: number;
|
||||
size?: keyof typeof sizes;
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
SM: "text-xs leading-[12px]",
|
||||
MD: "text-sm leading-[14px]",
|
||||
};
|
||||
|
||||
const variants = cva({
|
||||
variants: {
|
||||
size: sizes,
|
||||
},
|
||||
});
|
||||
|
||||
export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props) {
|
||||
const textStyle = variants({ size });
|
||||
return (
|
||||
<Card className="!inline-flex w-auto select-none items-center justify-center gap-x-2 rounded-lg p-1">
|
||||
{[...Array(nSteps).keys()].map(i => {
|
||||
if (i < currStepIdx) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex items-center justify-center rounded-full border border-blue-800 bg-blue-700 text-slate-600 dark:border-blue-300",
|
||||
textStyle,
|
||||
size === "SM" ? "h-5 w-5" : "h-6 w-6",
|
||||
)}
|
||||
key={`${i}-${currStepIdx}`}
|
||||
>
|
||||
<CheckIcon className="h-3.5 w-3.5 text-white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (i === currStepIdx) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"rounded-md border border-blue-800 bg-blue-700 px-2 py-1 font-medium text-white shadow-sm dark:border-blue-300",
|
||||
textStyle,
|
||||
)}
|
||||
key={`${i}-${currStepIdx}`}
|
||||
>
|
||||
Step {i + 1}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (i > currStepIdx) {
|
||||
return (
|
||||
<Card
|
||||
className={cx(
|
||||
"flex items-center justify-center !rounded-full text-slate-600 dark:text-slate-400",
|
||||
textStyle,
|
||||
size === "SM" ? "h-5 w-5" : "h-6 w-6",
|
||||
)}
|
||||
key={`${i}-${currStepIdx}`}
|
||||
>
|
||||
{i + 1}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
51
ui/src/components/Terminal.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import "react-simple-keyboard/build/css/index.css";
|
||||
import { useUiStore, useRTCStore } from "@/hooks/stores";
|
||||
import { XTerm } from "./Xterm";
|
||||
import { Button } from "./Button";
|
||||
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||
import { cx } from "../cva.config";
|
||||
import { Transition } from "@headlessui/react";
|
||||
|
||||
function TerminalWrapper() {
|
||||
const enableTerminal = useUiStore(state => state.enableTerminal);
|
||||
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
|
||||
const terminalChannel = useRTCStore(state => state.terminalChannel);
|
||||
|
||||
return (
|
||||
<div onKeyDown={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}>
|
||||
<Transition show={enableTerminal} appear>
|
||||
<div
|
||||
className={cx([
|
||||
// Base styles
|
||||
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
|
||||
"translate-y-[0px]",
|
||||
"data-[enter]:translate-y-[500px]",
|
||||
"data-[closed]:translate-y-[500px]",
|
||||
])}
|
||||
>
|
||||
<div className="h-[500px] w-full bg-[#0f172a]">
|
||||
<div className="flex items-center justify-center px-2 py-1 bg-white dark:bg-slate-800 border-y border-y-slate-800/30 dark:border-y-slate-300/20">
|
||||
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
||||
Web Terminal
|
||||
</h2>
|
||||
<div className="absolute right-2">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Hide"
|
||||
LeadingIcon={ChevronDownIcon}
|
||||
onClick={() => setEnableTerminal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[calc(100%-36px)] p-3">
|
||||
<XTerm terminalChannel={terminalChannel} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TerminalWrapper;
|
||||
56
ui/src/components/TextArea.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
import clsx from "clsx";
|
||||
import { FieldError } from "@/components/InputField";
|
||||
import Card from "@/components/Card";
|
||||
import { cx } from "@/cva.config";
|
||||
|
||||
type TextAreaProps = JSX.IntrinsicElements["textarea"] & {
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
function TextArea(props, ref) {
|
||||
return (
|
||||
<Card
|
||||
className={cx(
|
||||
"relative w-full",
|
||||
"invalid-within::ring-2 invalid-within::ring-red-600 invalid-within::ring-offset-2",
|
||||
"focus-within:border-slate-300 focus-within:outline-none focus-within:ring-1 focus-within:ring-blue-700 dark:focus-within:border-slate-600",
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={ref}
|
||||
{...props}
|
||||
id="asd"
|
||||
className={clsx(
|
||||
"block w-full rounded border-transparent bg-transparent text-black placeholder:text-slate-300 focus:ring-0 disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-300 dark:text-white dark:placeholder:text-slate-500 dark:disabled:bg-slate-800 sm:text-sm",
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type TextAreaWithLabelProps = {
|
||||
label: string | React.ReactNode;
|
||||
id?: string;
|
||||
description?: string;
|
||||
error?: string | null;
|
||||
} & React.ComponentProps<typeof TextArea>;
|
||||
|
||||
export const TextAreaWithLabel = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
TextAreaWithLabelProps
|
||||
>(function TextAreaWithLabel({ label, error, id, description, ...props }, ref) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<FieldLabel label={label} id={id} description={description} />
|
||||
<TextArea ref={ref} {...props} />
|
||||
{error && <FieldError error={error} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TextArea;
|
||||
97
ui/src/components/USBStateStatus.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { cx } from "@/cva.config";
|
||||
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
|
||||
import React from "react";
|
||||
import LoadingSpinner from "@components/LoadingSpinner";
|
||||
import StatusCard from "@components/StatusCards";
|
||||
import { HidState } from "@/hooks/stores";
|
||||
|
||||
type USBStates = HidState["usbState"];
|
||||
|
||||
type StatusProps = {
|
||||
[key in USBStates]: {
|
||||
icon: React.FC<{ className: string | undefined }>;
|
||||
iconClassName: string;
|
||||
statusIndicatorClassName: string;
|
||||
};
|
||||
};
|
||||
|
||||
const USBStateMap: {
|
||||
[key in USBStates]: string;
|
||||
} = {
|
||||
configured: "Connected",
|
||||
attached: "Connecting",
|
||||
addressed: "Connecting",
|
||||
"not attached": "Disconnected",
|
||||
suspended: "Low power mode",
|
||||
};
|
||||
|
||||
export default function USBStateStatus({
|
||||
state,
|
||||
peerConnectionState,
|
||||
}: {
|
||||
state: USBStates;
|
||||
peerConnectionState?: RTCPeerConnectionState;
|
||||
}) {
|
||||
|
||||
const StatusCardProps: StatusProps = {
|
||||
configured: {
|
||||
icon: ({ className }) => (
|
||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||
),
|
||||
iconClassName: "h-5 w-5 shrink-0",
|
||||
statusIndicatorClassName: "bg-green-500 border-green-600",
|
||||
},
|
||||
attached: {
|
||||
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
|
||||
iconClassName: "h-5 w-5 text-blue-500",
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
addressed: {
|
||||
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
|
||||
iconClassName: "h-5 w-5 text-blue-500",
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
"not attached": {
|
||||
icon: ({ className }) => (
|
||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||
),
|
||||
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
suspended: {
|
||||
icon: ({ className }) => (
|
||||
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
|
||||
),
|
||||
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
|
||||
statusIndicatorClassName: "bg-green-500 border-green-600",
|
||||
},
|
||||
};
|
||||
const props = StatusCardProps[state];
|
||||
if (!props) {
|
||||
console.log("Unsupport USB state: ", state);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the peer connection is not connected, show the USB cable as disconnected
|
||||
if (peerConnectionState !== "connected") {
|
||||
const {
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
statusIndicatorClassName,
|
||||
} = StatusCardProps["not attached"];
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
title="USB"
|
||||
status="Disconnected"
|
||||
icon={Icon}
|
||||
iconClassName={iconClassName}
|
||||
statusIndicatorClassName={statusIndicatorClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusCard title="USB" status={USBStateMap[state]} {...StatusCardProps[state]} />
|
||||
);
|
||||
}
|
||||
551
ui/src/components/UpdateDialog.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
import Card, { GridCard } from "@/components/Card";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import Modal from "@components/Modal";
|
||||
import { UpdateState, useUpdateStore } from "@/hooks/stores";
|
||||
import notifications from "@/notifications";
|
||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
local: { appVersion: string; systemVersion: string };
|
||||
remote: { appVersion: string; systemVersion: string };
|
||||
systemUpdateAvailable: boolean;
|
||||
appUpdateAvailable: boolean;
|
||||
}
|
||||
|
||||
export default function UpdateDialog({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
// We need to keep track of the update state in the dialog even if the dialog is minimized
|
||||
const { setModalView } = useUpdateStore();
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const onConfirmUpdate = useCallback(() => {
|
||||
send("tryUpdate", {});
|
||||
setModalView("updating");
|
||||
}, [send, setModalView]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={() => setOpen(false)}>
|
||||
<Dialog setOpen={setOpen} onConfirmUpdate={onConfirmUpdate} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
setOpen,
|
||||
onConfirmUpdate,
|
||||
}: {
|
||||
setOpen: (open: boolean) => void;
|
||||
onConfirmUpdate: () => void;
|
||||
}) {
|
||||
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
|
||||
const { modalView, setModalView, otaState } = useUpdateStore();
|
||||
|
||||
const onFinishedLoading = useCallback(
|
||||
async (versionInfo: SystemVersionInfo) => {
|
||||
const hasUpdate =
|
||||
versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable;
|
||||
|
||||
setVersionInfo(versionInfo);
|
||||
|
||||
if (hasUpdate) {
|
||||
setModalView("updateAvailable");
|
||||
} else {
|
||||
setModalView("upToDate");
|
||||
}
|
||||
},
|
||||
[setModalView],
|
||||
);
|
||||
|
||||
// Reset modal view when dialog is opened
|
||||
useEffect(() => {
|
||||
setVersionInfo(null);
|
||||
}, [setModalView]);
|
||||
|
||||
return (
|
||||
<GridCard cardClassName="mx-auto relative max-w-md text-left pointer-events-auto">
|
||||
<div className="p-10">
|
||||
{modalView === "error" && (
|
||||
<UpdateErrorState
|
||||
errorMessage={otaState.error}
|
||||
onClose={() => setOpen(false)}
|
||||
onRetryUpdate={() => setModalView("loading")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "loading" && (
|
||||
<LoadingState
|
||||
onFinished={onFinishedLoading}
|
||||
onCancelCheck={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updateAvailable" && (
|
||||
<UpdateAvailableState
|
||||
onConfirmUpdate={onConfirmUpdate}
|
||||
onClose={() => setOpen(false)}
|
||||
versionInfo={versionInfo!}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updating" && (
|
||||
<UpdatingDeviceState
|
||||
otaState={otaState}
|
||||
onMinimizeUpgradeDialog={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "upToDate" && (
|
||||
<SystemUpToDateState
|
||||
checkUpdate={() => setModalView("loading")}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updateCompleted" && (
|
||||
<UpdateCompletedState onClose={() => setOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState({
|
||||
onFinished,
|
||||
onCancelCheck,
|
||||
}: {
|
||||
onFinished: (versionInfo: SystemVersionInfo) => void;
|
||||
onCancelCheck: () => void;
|
||||
}) {
|
||||
const [progressWidth, setProgressWidth] = useState("0%");
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const getVersionInfo = useCallback(() => {
|
||||
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
||||
send("getUpdateStatus", {}, async resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error("Failed to check for updates");
|
||||
reject(new Error("Failed to check for updates"));
|
||||
} else {
|
||||
const result = resp.result as SystemVersionInfo;
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
setProgressWidth("0%");
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
const signal = abortControllerRef.current.signal;
|
||||
|
||||
const animationTimer = setTimeout(() => {
|
||||
setProgressWidth("100%");
|
||||
}, 500);
|
||||
|
||||
getVersionInfo()
|
||||
.then(versionInfo => {
|
||||
if (progressBarRef.current) {
|
||||
progressBarRef.current?.classList.add("!duration-1000");
|
||||
}
|
||||
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 1000));
|
||||
})
|
||||
.then(versionInfo => {
|
||||
if (!signal.aborted) {
|
||||
onFinished(versionInfo as SystemVersionInfo);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (!signal.aborted) {
|
||||
console.error("LoadingState: Error fetching version info", error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearTimeout(animationTimer);
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [getVersionInfo, onFinished]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="max-w-sm space-y-4">
|
||||
<div className="space-y-0">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
Checking for updates...
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
We{"'"}re ensuring your device has the latest features and improvements.
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
style={{ width: progressWidth }}
|
||||
className="h-2.5 bg-blue-700 transition-all duration-[4s] ease-in-out"
|
||||
></div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Cancel"
|
||||
onClick={() => {
|
||||
onCancelCheck();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdatingDeviceState({
|
||||
otaState,
|
||||
onMinimizeUpgradeDialog,
|
||||
}: {
|
||||
otaState: UpdateState["otaState"];
|
||||
onMinimizeUpgradeDialog: () => void;
|
||||
}) {
|
||||
const formatProgress = (progress: number) => `${Math.round(progress)}%`;
|
||||
|
||||
const calculateOverallProgress = (type: "system" | "app") => {
|
||||
const downloadProgress = Math.round((otaState[`${type}DownloadProgress`] || 0) * 100);
|
||||
const updateProgress = Math.round((otaState[`${type}UpdateProgress`] || 0) * 100);
|
||||
const verificationProgress = Math.round(
|
||||
(otaState[`${type}VerificationProgress`] || 0) * 100,
|
||||
);
|
||||
|
||||
if (!downloadProgress && !updateProgress && !verificationProgress) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`For ${type}:\n` +
|
||||
` Download Progress: ${downloadProgress}% (${otaState[`${type}DownloadProgress`]})\n` +
|
||||
` Update Progress: ${updateProgress}% (${otaState[`${type}UpdateProgress`]})\n` +
|
||||
` Verification Progress: ${verificationProgress}% (${otaState[`${type}VerificationProgress`]})`,
|
||||
);
|
||||
|
||||
if (type === "app") {
|
||||
// App: 65% download, 34% verification, 1% update(There is no "real" update for the app)
|
||||
return Math.min(
|
||||
downloadProgress * 0.55 + verificationProgress * 0.54 + updateProgress * 0.01,
|
||||
100,
|
||||
);
|
||||
} else {
|
||||
// System: 10% download, 90% update
|
||||
return Math.min(
|
||||
downloadProgress * 0.1 + verificationProgress * 0.1 + updateProgress * 0.8,
|
||||
100,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getUpdateStatus = (type: "system" | "app") => {
|
||||
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
|
||||
const verfiedAt = otaState[`${type}VerifiedAt`];
|
||||
const updatedAt = otaState[`${type}UpdatedAt`];
|
||||
|
||||
if (!otaState.metadataFetchedAt) {
|
||||
return "Fetching update information...";
|
||||
} else if (!downloadFinishedAt) {
|
||||
return `Downloading ${type} update...`;
|
||||
} else if (!verfiedAt) {
|
||||
return `Verifying ${type} update...`;
|
||||
} else if (!updatedAt) {
|
||||
return `Installing ${type} update...`;
|
||||
} else {
|
||||
return `Awaiting reboot`;
|
||||
}
|
||||
};
|
||||
|
||||
const isUpdateComplete = (type: "system" | "app") => {
|
||||
return !!otaState[`${type}UpdatedAt`];
|
||||
};
|
||||
|
||||
const areAllUpdatesComplete = () => {
|
||||
if (otaState.systemUpdatePending && otaState.appUpdatePending) {
|
||||
return isUpdateComplete("system") && isUpdateComplete("app");
|
||||
}
|
||||
return (
|
||||
(otaState.systemUpdatePending && isUpdateComplete("system")) ||
|
||||
(otaState.appUpdatePending && isUpdateComplete("app"))
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="w-full max-w-sm space-y-4">
|
||||
<div className="space-y-0">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
Updating your device
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Please don{"'"}t turn off your device. This process may take a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
<Card className="p-4 space-y-4">
|
||||
{areAllUpdatesComplete() ? (
|
||||
<div className="flex flex-col items-center my-2 space-y-2 text-center">
|
||||
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
|
||||
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
|
||||
<span className="font-medium text-black dark:text-white">
|
||||
Rebooting to complete the update...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!(otaState.systemUpdatePending || otaState.appUpdatePending) && (
|
||||
<div className="flex flex-col items-center my-2 space-y-2 text-center">
|
||||
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otaState.systemUpdatePending && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-black dark:text-white">
|
||||
Linux System Update
|
||||
</p>
|
||||
{calculateOverallProgress("system") < 100 ? (
|
||||
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" />
|
||||
) : (
|
||||
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
|
||||
<div
|
||||
className="h-2.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
|
||||
style={{
|
||||
width: formatProgress(calculateOverallProgress("system")),
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
|
||||
<span>{getUpdateStatus("system")}</span>
|
||||
{calculateOverallProgress("system") < 100 ? (
|
||||
<span>{formatProgress(calculateOverallProgress("system"))}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{otaState.appUpdatePending && (
|
||||
<>
|
||||
{otaState.systemUpdatePending && (
|
||||
<hr className="dark:border-slate-600" />
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-black dark:text-white">
|
||||
App Update
|
||||
</p>
|
||||
{calculateOverallProgress("app") < 100 ? (
|
||||
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" />
|
||||
) : (
|
||||
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
|
||||
<div
|
||||
className="h-2.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
|
||||
style={{
|
||||
width: formatProgress(calculateOverallProgress("app")),
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
|
||||
<span>{getUpdateStatus("app")}</span>
|
||||
{calculateOverallProgress("system") < 100 ? (
|
||||
<span>{formatProgress(calculateOverallProgress("app"))}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<div className="flex justify-start mt-4 text-white gap-x-2">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Update in Background"
|
||||
onClick={onMinimizeUpgradeDialog}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemUpToDateState({
|
||||
checkUpdate,
|
||||
onClose,
|
||||
}: {
|
||||
checkUpdate: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
System is up to date
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Your system is running the latest version. No updates are currently available.
|
||||
</p>
|
||||
|
||||
<div className="flex mt-4 gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Check Again"
|
||||
onClick={() => {
|
||||
checkUpdate();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text="Close"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateAvailableState({
|
||||
versionInfo,
|
||||
onConfirmUpdate,
|
||||
onClose,
|
||||
}: {
|
||||
versionInfo: SystemVersionInfo;
|
||||
onConfirmUpdate: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
Update available
|
||||
</p>
|
||||
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
A new update is available to enhance system performance and improve
|
||||
compatibility. We recommend updating to ensure everything runs smoothly.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
|
||||
{versionInfo?.systemUpdateAvailable ? (
|
||||
<>
|
||||
<span className="font-semibold">System:</span>{" "}
|
||||
{versionInfo?.remote.systemVersion}
|
||||
<br />
|
||||
</>
|
||||
) : null}
|
||||
{versionInfo?.appUpdateAvailable ? (
|
||||
<>
|
||||
<span className="font-semibold">App:</span> {versionInfo?.remote.appVersion}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="flex items-center justify-start gap-x-2">
|
||||
<Button size="SM" theme="primary" text="Update Now" onClick={onConfirmUpdate} />
|
||||
<Button size="SM" theme="light" text="Do it later" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateCompletedState({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold dark:text-white">
|
||||
Update Completed Successfully
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
Your device has been successfully updated to the latest version. Enjoy the new
|
||||
features and improvements!
|
||||
</p>
|
||||
<div className="flex items-center justify-start">
|
||||
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateErrorState({
|
||||
errorMessage,
|
||||
onClose,
|
||||
onRetryUpdate,
|
||||
}: {
|
||||
errorMessage: string | null;
|
||||
onClose: () => void;
|
||||
onRetryUpdate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold dark:text-white">Update Error</p>
|
||||
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
An error occurred while updating your device. Please try again later.
|
||||
</p>
|
||||
{errorMessage && (
|
||||
<p className="mb-4 text-sm font-medium text-red-600 dark:text-red-400">
|
||||
Error details: {errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-start gap-x-2">
|
||||
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
|
||||
<Button size="SM" theme="primary" text="Retry" onClick={onRetryUpdate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
ui/src/components/UpdateInProgressStatusCard.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { cx } from "@/cva.config";
|
||||
import { Button } from "./Button";
|
||||
import { GridCard } from "./Card";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import { UpdateState } from "@/hooks/stores";
|
||||
|
||||
interface UpdateInProgressStatusCardProps {
|
||||
setIsUpdateDialogOpen: (isOpen: boolean) => void;
|
||||
setModalView: (view: UpdateState["modalView"]) => void;
|
||||
}
|
||||
|
||||
export default function UpdateInProgressStatusCard({
|
||||
setIsUpdateDialogOpen,
|
||||
setModalView,
|
||||
}: UpdateInProgressStatusCardProps) {
|
||||
return (
|
||||
<div className="w-full transition-all duration-300 ease-in-out opacity-100 select-none">
|
||||
<GridCard cardClassName="!shadow-xl">
|
||||
<div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold leading-none transition text-ellipsis">
|
||||
Update in Progress
|
||||
</div>
|
||||
<div className="text-sm leading-none">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span className={cx("transition")}>
|
||||
Please don{"'"}t turn off your device...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="SM"
|
||||
className="pointer-events-auto"
|
||||
theme="light"
|
||||
text="View Details"
|
||||
onClick={() => {
|
||||
setModalView("updating");
|
||||
setIsUpdateDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</GridCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
ui/src/components/VideoOverlay.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React from "react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import { ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||
import { LinkButton } from "@components/Button";
|
||||
import LoadingSpinner from "@components/LoadingSpinner";
|
||||
import { GridCard } from "@components/Card";
|
||||
|
||||
interface OverlayContentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
function OverlayContent({ children }: OverlayContentProps) {
|
||||
return (
|
||||
<GridCard cardClassName="h-full pointer-events-auto !outline-none">
|
||||
<div className="flex flex-col items-center justify-center w-full h-full border rounded-md border-slate-800/30 dark:border-slate-300/20">
|
||||
{children}
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ show }: LoadingOverlayProps) {
|
||||
return (
|
||||
<Transition
|
||||
show={show}
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute inset-0 w-full h-full aspect-video">
|
||||
<OverlayContent>
|
||||
<div className="flex flex-col items-center justify-center gap-y-1">
|
||||
<div className="flex items-center justify-center w-12 h-12 animate">
|
||||
<LoadingSpinner className="w-8 h-8 text-blue-800 dark:text-blue-200" />
|
||||
</div>
|
||||
<p className="text-sm text-center text-slate-700 dark:text-slate-300">
|
||||
Loading video stream...
|
||||
</p>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionErrorOverlayProps {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
|
||||
return (
|
||||
<Transition
|
||||
show={show}
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute inset-0 z-10 w-full h-full aspect-video">
|
||||
<OverlayContent>
|
||||
<div className="flex flex-col items-start gap-y-1">
|
||||
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
|
||||
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-black dark:text-white">
|
||||
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
|
||||
<ul className="pl-4 space-y-2 text-left list-disc">
|
||||
<li>Verify that the device is powered on and properly connected</li>
|
||||
<li>Check all cable connections for any loose or damaged wires</li>
|
||||
<li>Ensure your network connection is stable and active</li>
|
||||
<li>Try restarting both the device and your computer</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<LinkButton
|
||||
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
||||
theme="light"
|
||||
text="Troubleshooting Guide"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
size="SM"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
interface HDMIErrorOverlayProps {
|
||||
show: boolean;
|
||||
hdmiState: string;
|
||||
}
|
||||
|
||||
export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||
const isNoSignal = hdmiState === "no_signal";
|
||||
const isOtherError = hdmiState === "no_lock" || hdmiState === "out_of_range";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition
|
||||
show={show && isNoSignal}
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-all duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute inset-0 w-full h-full aspect-video">
|
||||
<OverlayContent>
|
||||
<div className="flex flex-col items-start gap-y-1">
|
||||
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
|
||||
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-black dark:text-white">
|
||||
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
|
||||
<ul className="pl-4 space-y-2 text-left list-disc">
|
||||
<li>Ensure the HDMI cable securely connected at both ends</li>
|
||||
<li>Ensure source device is powered on and outputting a signal</li>
|
||||
<li>
|
||||
If using an adapter, it's compatible and functioning
|
||||
correctly
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<LinkButton
|
||||
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
||||
theme="light"
|
||||
text="Learn more"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
size="SM"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
show={show && isOtherError}
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition duration-300 "
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute inset-0 w-full h-full aspect-video">
|
||||
<OverlayContent>
|
||||
<div className="flex flex-col items-start gap-y-1">
|
||||
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
|
||||
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-black dark:text-white">
|
||||
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
|
||||
<ul className="pl-4 space-y-2 text-left list-disc">
|
||||
<li>A loose or faulty HDMI connection</li>
|
||||
<li>Incompatible resolution or refresh rate settings</li>
|
||||
<li>Issues with the source device's HDMI output</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<LinkButton
|
||||
to={"/help/hdmi-error"}
|
||||
theme="light"
|
||||
text="Learn more"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
size="SM"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</div>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
459
ui/src/components/VirtualKeyboard.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Keyboard from "react-simple-keyboard";
|
||||
import { Button } from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||
import "react-simple-keyboard/build/css/index.css";
|
||||
import { useHidStore, useUiStore } from "@/hooks/stores";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { cx } from "@/cva.config";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import useKeyboard from "@/hooks/useKeyboard";
|
||||
import DetachIconRaw from "@/assets/detach-icon.svg";
|
||||
import AttachIconRaw from "@/assets/attach-icon.svg";
|
||||
|
||||
export const DetachIcon = ({ className }: { className?: string }) => {
|
||||
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
|
||||
};
|
||||
|
||||
const AttachIcon = ({ className }: { className?: string }) => {
|
||||
return <img src={AttachIconRaw} alt="Attach Icon" className={className} />;
|
||||
};
|
||||
|
||||
function KeyboardWrapper() {
|
||||
const [layoutName, setLayoutName] = useState("default");
|
||||
|
||||
const keyboardRef = useRef<HTMLDivElement>(null);
|
||||
const showAttachedVirtualKeyboard = useUiStore(
|
||||
state => state.isAttachedVirtualKeyboardVisible,
|
||||
);
|
||||
const setShowAttachedVirtualKeyboard = useUiStore(
|
||||
state => state.setAttachedVirtualKeyboardVisibility,
|
||||
);
|
||||
|
||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
|
||||
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
|
||||
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
|
||||
|
||||
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
|
||||
if (!keyboardRef.current) return;
|
||||
if (e instanceof TouchEvent && e.touches.length > 1) return;
|
||||
setIsDragging(true);
|
||||
|
||||
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
const rect = keyboardRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onDrag = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
if (!keyboardRef.current) return;
|
||||
if (isDragging) {
|
||||
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
const newX = clientX - position.x;
|
||||
const newY = clientY - position.y;
|
||||
|
||||
const rect = keyboardRef.current.getBoundingClientRect();
|
||||
const maxX = window.innerWidth - rect.width;
|
||||
const maxY = window.innerHeight - rect.height;
|
||||
|
||||
setNewPosition({
|
||||
x: Math.min(maxX, Math.max(0, newX)),
|
||||
y: Math.min(maxY, Math.max(0, newY)),
|
||||
});
|
||||
}
|
||||
},
|
||||
[isDragging, position.x, position.y],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = keyboardRef.current;
|
||||
if (handle) {
|
||||
handle.addEventListener("touchstart", startDrag);
|
||||
handle.addEventListener("mousedown", startDrag);
|
||||
}
|
||||
|
||||
document.addEventListener("mouseup", endDrag);
|
||||
document.addEventListener("touchend", endDrag);
|
||||
|
||||
document.addEventListener("mousemove", onDrag);
|
||||
document.addEventListener("touchmove", onDrag);
|
||||
|
||||
return () => {
|
||||
if (handle) {
|
||||
handle.removeEventListener("touchstart", startDrag);
|
||||
handle.removeEventListener("mousedown", startDrag);
|
||||
}
|
||||
|
||||
document.removeEventListener("mouseup", endDrag);
|
||||
document.removeEventListener("touchend", endDrag);
|
||||
|
||||
document.removeEventListener("mousemove", onDrag);
|
||||
document.removeEventListener("touchmove", onDrag);
|
||||
};
|
||||
}, [endDrag, onDrag, startDrag]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(key: string) => {
|
||||
const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
|
||||
const isKeyCaps = key === "CapsLock";
|
||||
const cleanKey = key.replace(/[()]/g, "");
|
||||
const keyHasShiftModifier = key.includes("(");
|
||||
|
||||
// Handle toggle of layout for shift or caps lock
|
||||
const toggleLayout = () => {
|
||||
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
|
||||
};
|
||||
|
||||
if (key === "CtrlAltDelete") {
|
||||
sendKeyboardEvent(
|
||||
[keys["Delete"]],
|
||||
[modifiers["ControlLeft"], modifiers["AltLeft"]],
|
||||
);
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "AltMetaEscape") {
|
||||
sendKeyboardEvent(
|
||||
[keys["Escape"]],
|
||||
[modifiers["MetaLeft"], modifiers["AltLeft"]],
|
||||
);
|
||||
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isKeyShift || isKeyCaps) {
|
||||
toggleLayout();
|
||||
|
||||
if (isCapsLockActive) {
|
||||
setIsCapsLockActive(false);
|
||||
sendKeyboardEvent([keys["CapsLock"]], []);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle caps lock state change
|
||||
if (isKeyCaps) {
|
||||
setIsCapsLockActive(!isCapsLockActive);
|
||||
}
|
||||
|
||||
// Collect new active keys and modifiers
|
||||
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
|
||||
const newModifiers =
|
||||
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
|
||||
|
||||
// Update current keys and modifiers
|
||||
sendKeyboardEvent(newKeys, newModifiers);
|
||||
|
||||
// If shift was used as a modifier and caps lock is not active, revert to default layout
|
||||
if (keyHasShiftModifier && !isCapsLockActive) {
|
||||
setLayoutName("default");
|
||||
}
|
||||
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
},
|
||||
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
|
||||
);
|
||||
|
||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="transition-all duration-500 ease-in-out"
|
||||
style={{
|
||||
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
|
||||
}}
|
||||
>
|
||||
<Transition
|
||||
show={virtualKeyboard}
|
||||
unmount={false}
|
||||
enter="transition-all transform-gpu duration-500 ease-in-out"
|
||||
enterFrom="opacity-0 translate-y-[100%]"
|
||||
enterTo="opacity-100 translate-y-[0%]"
|
||||
leave="transition-all duration-500 ease-in-out"
|
||||
leaveFrom="opacity-100 translate-y-[0%]"
|
||||
leaveTo="opacity-0 translate-y-[100%]"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={cx(
|
||||
!showAttachedVirtualKeyboard
|
||||
? "fixed left-0 top-0 z-50 select-none"
|
||||
: "relative",
|
||||
)}
|
||||
ref={keyboardRef}
|
||||
style={{
|
||||
...(!showAttachedVirtualKeyboard
|
||||
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className={cx("overflow-hidden", {
|
||||
"rounded-none": showAttachedVirtualKeyboard,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-center px-2 py-1 bg-white border-b dark:bg-slate-800 border-b-slate-800/30 dark:border-b-slate-300/20">
|
||||
<div className="absolute flex items-center left-2 gap-x-2">
|
||||
{showAttachedVirtualKeyboard ? (
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Detach"
|
||||
onClick={() => setShowAttachedVirtualKeyboard(false)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Attach"
|
||||
LeadingIcon={AttachIcon}
|
||||
onClick={() => setShowAttachedVirtualKeyboard(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
||||
Virtual Keyboard
|
||||
</h2>
|
||||
<div className="absolute right-2">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Hide"
|
||||
LeadingIcon={ChevronDownIcon}
|
||||
onClick={() => setVirtualKeyboard(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-col dark:bg-slate-700 bg-blue-50/80 md:flex-row">
|
||||
<Keyboard
|
||||
baseClass="simple-keyboard-main"
|
||||
layoutName={layoutName}
|
||||
onKeyPress={onKeyDown}
|
||||
buttonTheme={[
|
||||
{
|
||||
class: "combination-key",
|
||||
buttons: "CtrlAltDelete AltMetaEscape",
|
||||
},
|
||||
]}
|
||||
display={{
|
||||
CtrlAltDelete: "Ctrl + Alt + Delete",
|
||||
AltMetaEscape: "Alt + Meta + Escape",
|
||||
Escape: "esc",
|
||||
Tab: "tab",
|
||||
Backspace: "backspace",
|
||||
"(Backspace)": "backspace",
|
||||
Enter: "enter",
|
||||
CapsLock: "caps lock",
|
||||
ShiftLeft: "shift",
|
||||
ShiftRight: "shift",
|
||||
ControlLeft: "ctrl",
|
||||
AltLeft: "alt",
|
||||
AltRight: "alt",
|
||||
MetaLeft: "meta",
|
||||
MetaRight: "meta",
|
||||
KeyQ: "q",
|
||||
KeyW: "w",
|
||||
KeyE: "e",
|
||||
KeyR: "r",
|
||||
KeyT: "t",
|
||||
KeyY: "y",
|
||||
KeyU: "u",
|
||||
KeyI: "i",
|
||||
KeyO: "o",
|
||||
KeyP: "p",
|
||||
KeyA: "a",
|
||||
KeyS: "s",
|
||||
KeyD: "d",
|
||||
KeyF: "f",
|
||||
KeyG: "g",
|
||||
KeyH: "h",
|
||||
KeyJ: "j",
|
||||
KeyK: "k",
|
||||
KeyL: "l",
|
||||
KeyZ: "z",
|
||||
KeyX: "x",
|
||||
KeyC: "c",
|
||||
KeyV: "v",
|
||||
KeyB: "b",
|
||||
KeyN: "n",
|
||||
KeyM: "m",
|
||||
|
||||
"(KeyQ)": "Q",
|
||||
"(KeyW)": "W",
|
||||
"(KeyE)": "E",
|
||||
"(KeyR)": "R",
|
||||
"(KeyT)": "T",
|
||||
"(KeyY)": "Y",
|
||||
"(KeyU)": "U",
|
||||
"(KeyI)": "I",
|
||||
"(KeyO)": "O",
|
||||
"(KeyP)": "P",
|
||||
"(KeyA)": "A",
|
||||
"(KeyS)": "S",
|
||||
"(KeyD)": "D",
|
||||
"(KeyF)": "F",
|
||||
"(KeyG)": "G",
|
||||
"(KeyH)": "H",
|
||||
"(KeyJ)": "J",
|
||||
"(KeyK)": "K",
|
||||
"(KeyL)": "L",
|
||||
"(KeyZ)": "Z",
|
||||
"(KeyX)": "X",
|
||||
"(KeyC)": "C",
|
||||
"(KeyV)": "V",
|
||||
"(KeyB)": "B",
|
||||
"(KeyN)": "N",
|
||||
"(KeyM)": "M",
|
||||
Digit1: "1",
|
||||
Digit2: "2",
|
||||
Digit3: "3",
|
||||
Digit4: "4",
|
||||
Digit5: "5",
|
||||
Digit6: "6",
|
||||
Digit7: "7",
|
||||
Digit8: "8",
|
||||
Digit9: "9",
|
||||
Digit0: "0",
|
||||
|
||||
"(Digit1)": "!",
|
||||
"(Digit2)": "@",
|
||||
"(Digit3)": "#",
|
||||
"(Digit4)": "$",
|
||||
"(Digit5)": "%",
|
||||
"(Digit6)": "^",
|
||||
"(Digit7)": "&",
|
||||
"(Digit8)": "*",
|
||||
"(Digit9)": "(",
|
||||
"(Digit0)": ")",
|
||||
Minus: "-",
|
||||
"(Minus)": "_",
|
||||
|
||||
Equal: "=",
|
||||
"(Equal)": "+",
|
||||
BracketLeft: "[",
|
||||
BracketRight: "]",
|
||||
"(BracketLeft)": "{",
|
||||
"(BracketRight)": "}",
|
||||
Backslash: "\\",
|
||||
"(Backslash)": "|",
|
||||
|
||||
Semicolon: ";",
|
||||
"(Semicolon)": ":",
|
||||
Quote: "'",
|
||||
"(Quote)": '"',
|
||||
Comma: ",",
|
||||
"(Comma)": "<",
|
||||
Period: ".",
|
||||
"(Period)": ">",
|
||||
Slash: "/",
|
||||
"(Slash)": "?",
|
||||
Space: " ",
|
||||
Backquote: "`",
|
||||
"(Backquote)": "~",
|
||||
IntlBackslash: "\\",
|
||||
|
||||
F1: "F1",
|
||||
F2: "F2",
|
||||
F3: "F3",
|
||||
F4: "F4",
|
||||
F5: "F5",
|
||||
F6: "F6",
|
||||
F7: "F7",
|
||||
F8: "F8",
|
||||
F9: "F9",
|
||||
F10: "F10",
|
||||
F11: "F11",
|
||||
F12: "F12",
|
||||
}}
|
||||
layout={{
|
||||
default: [
|
||||
"CtrlAltDelete AltMetaEscape",
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
|
||||
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
|
||||
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
|
||||
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
|
||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||
],
|
||||
shift: [
|
||||
"CtrlAltDelete AltMetaEscape",
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
|
||||
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
||||
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
|
||||
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
|
||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||
],
|
||||
}}
|
||||
disableButtonHold={true}
|
||||
mergeDisplay={true}
|
||||
debug={false}
|
||||
/>
|
||||
|
||||
<div className="controlArrows">
|
||||
<Keyboard
|
||||
baseClass="simple-keyboard-control"
|
||||
theme="simple-keyboard hg-theme-default hg-layout-default"
|
||||
layout={{
|
||||
default: ["Home Pageup", "Delete End Pagedown"],
|
||||
}}
|
||||
display={{
|
||||
Home: "home",
|
||||
Pageup: "pageup",
|
||||
Delete: "delete",
|
||||
End: "end",
|
||||
Pagedown: "pagedown",
|
||||
}}
|
||||
syncInstanceInputs={true}
|
||||
onKeyPress={onKeyDown}
|
||||
mergeDisplay={true}
|
||||
debug={false}
|
||||
/>
|
||||
<Keyboard
|
||||
baseClass="simple-keyboard-arrows"
|
||||
theme="simple-keyboard hg-theme-default hg-layout-default"
|
||||
display={{
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
}}
|
||||
layout={{
|
||||
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
|
||||
}}
|
||||
onKeyPress={onKeyDown}
|
||||
debug={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeyboardWrapper;
|
||||
461
ui/src/components/WebRTCVideo.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useHidStore,
|
||||
useMouseStore,
|
||||
useRTCStore,
|
||||
useSettingsStore,
|
||||
useUiStore,
|
||||
useVideoStore,
|
||||
} from "@/hooks/stores";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { useResizeObserver } from "@/hooks/useResizeObserver";
|
||||
import { cx } from "@/cva.config";
|
||||
import VirtualKeyboard from "@components/VirtualKeyboard";
|
||||
import Actionbar from "@components/ActionBar";
|
||||
import InfoBar from "@components/InfoBar";
|
||||
import useKeyboard from "@/hooks/useKeyboard";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay";
|
||||
|
||||
export default function WebRTCVideo() {
|
||||
// Video and stream related refs and states
|
||||
const videoElm = useRef<HTMLVideoElement>(null);
|
||||
const mediaStream = useRTCStore(state => state.mediaStream);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
// Store hooks
|
||||
const settings = useSettingsStore();
|
||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||
const setMousePosition = useMouseStore(state => state.setMousePosition);
|
||||
const {
|
||||
setClientSize: setVideoClientSize,
|
||||
setSize: setVideoSize,
|
||||
clientWidth: videoClientWidth,
|
||||
clientHeight: videoClientHeight,
|
||||
} = useVideoStore();
|
||||
|
||||
// RTC related states
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||
|
||||
// HDMI and UI states
|
||||
const hdmiState = useVideoStore(state => state.hdmiState);
|
||||
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
|
||||
const isLoading = !hdmiError && !isPlaying;
|
||||
const isConnectionError = ["error", "failed", "disconnected"].includes(
|
||||
peerConnectionState || "",
|
||||
);
|
||||
|
||||
// Keyboard related states
|
||||
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
|
||||
useHidStore();
|
||||
|
||||
// Misc states and hooks
|
||||
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
// Video-related
|
||||
useResizeObserver({
|
||||
ref: videoElm,
|
||||
onResize: ({ width, height }) => {
|
||||
// This is actually client size, not videoSize
|
||||
if (width && height) {
|
||||
if (!videoElm.current) return;
|
||||
setVideoClientSize(width, height);
|
||||
setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const updateVideoSizeStore = useCallback(
|
||||
(videoElm: HTMLVideoElement) => {
|
||||
setVideoClientSize(videoElm.clientWidth, videoElm.clientHeight);
|
||||
setVideoSize(videoElm.videoWidth, videoElm.videoHeight);
|
||||
},
|
||||
[setVideoClientSize, setVideoSize],
|
||||
);
|
||||
|
||||
const onVideoPlaying = useCallback(() => {
|
||||
setIsPlaying(true);
|
||||
videoElm.current && updateVideoSizeStore(videoElm.current);
|
||||
}, [updateVideoSizeStore]);
|
||||
|
||||
// On mount, get the video size
|
||||
useEffect(
|
||||
function updateVideoSizeOnMount() {
|
||||
videoElm.current && updateVideoSizeStore(videoElm.current);
|
||||
},
|
||||
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
|
||||
);
|
||||
|
||||
// Mouse-related
|
||||
const sendMouseMovement = useCallback(
|
||||
(x: number, y: number, buttons: number) => {
|
||||
send("absMouseReport", { x, y, buttons });
|
||||
|
||||
// We set that for the debug info bar
|
||||
setMousePosition(x, y);
|
||||
},
|
||||
[send, setMousePosition],
|
||||
);
|
||||
|
||||
const mouseMoveHandler = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!videoClientWidth || !videoClientHeight) return;
|
||||
const { buttons } = e;
|
||||
|
||||
// Clamp mouse position within the video boundaries
|
||||
const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth);
|
||||
const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight);
|
||||
|
||||
// Normalize mouse position to 0-32767 range (HID absolute coordinate system)
|
||||
const x = Math.round((currMouseX / videoClientWidth) * 32767);
|
||||
const y = Math.round((currMouseY / videoClientHeight) * 32767);
|
||||
|
||||
// Send mouse movement
|
||||
sendMouseMovement(x, y, buttons);
|
||||
},
|
||||
[sendMouseMovement, videoClientHeight, videoClientWidth],
|
||||
);
|
||||
|
||||
const mouseWheelHandler = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
if (blockWheelEvent) return;
|
||||
e.preventDefault();
|
||||
|
||||
// Define a scaling factor to adjust scrolling sensitivity
|
||||
const scrollSensitivity = 0.8; // Adjust this value to change scroll speed
|
||||
|
||||
// Calculate the scroll value
|
||||
const scroll = e.deltaY * scrollSensitivity;
|
||||
|
||||
// Clamp the scroll value to a reasonable range (e.g., -15 to 15)
|
||||
const clampedScroll = Math.max(-4, Math.min(4, scroll));
|
||||
|
||||
// Round to the nearest integer
|
||||
const roundedScroll = Math.round(clampedScroll);
|
||||
|
||||
// Invert the scroll value to match expected behavior
|
||||
const invertedScroll = -roundedScroll;
|
||||
|
||||
console.log("wheelReport", { wheelY: invertedScroll });
|
||||
send("wheelReport", { wheelY: invertedScroll });
|
||||
|
||||
setBlockWheelEvent(true);
|
||||
setTimeout(() => setBlockWheelEvent(false), 50);
|
||||
},
|
||||
[blockWheelEvent, send],
|
||||
);
|
||||
|
||||
const resetMousePosition = useCallback(() => {
|
||||
sendMouseMovement(0, 0, 0);
|
||||
}, [sendMouseMovement]);
|
||||
|
||||
// Keyboard-related
|
||||
const handleModifierKeys = useCallback(
|
||||
(e: KeyboardEvent, activeModifiers: number[]) => {
|
||||
const { shiftKey, ctrlKey, altKey, metaKey } = e;
|
||||
|
||||
const filteredModifiers = activeModifiers.filter(Boolean);
|
||||
|
||||
// Example: activeModifiers = [0x01, 0x02, 0x04, 0x08]
|
||||
// Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft
|
||||
return (
|
||||
filteredModifiers
|
||||
// Shift: Keep if Shift is pressed or if the key isn't a Shift key
|
||||
// Example: If shiftKey is true, keep all modifiers
|
||||
// If shiftKey is false, filter out 0x02 (ShiftLeft) and 0x20 (ShiftRight)
|
||||
.filter(
|
||||
modifier =>
|
||||
shiftKey ||
|
||||
(modifier !== modifiers["ShiftLeft"] &&
|
||||
modifier !== modifiers["ShiftRight"]),
|
||||
)
|
||||
// Ctrl: Keep if Ctrl is pressed or if the key isn't a Ctrl key
|
||||
// Example: If ctrlKey is true, keep all modifiers
|
||||
// If ctrlKey is false, filter out 0x01 (ControlLeft) and 0x10 (ControlRight)
|
||||
.filter(
|
||||
modifier =>
|
||||
ctrlKey ||
|
||||
(modifier !== modifiers["ControlLeft"] &&
|
||||
modifier !== modifiers["ControlRight"]),
|
||||
)
|
||||
// Alt: Keep if Alt is pressed or if the key isn't an Alt key
|
||||
// Example: If altKey is true, keep all modifiers
|
||||
// If altKey is false, filter out 0x04 (AltLeft) and 0x40 (AltRight)
|
||||
.filter(
|
||||
modifier =>
|
||||
altKey ||
|
||||
(modifier !== modifiers["AltLeft"] && modifier !== modifiers["AltRight"]),
|
||||
)
|
||||
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
|
||||
// Example: If metaKey is true, keep all modifiers
|
||||
// If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight)
|
||||
.filter(
|
||||
modifier =>
|
||||
metaKey ||
|
||||
(modifier !== modifiers["MetaLeft"] && modifier !== modifiers["MetaRight"]),
|
||||
)
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const keyDownHandler = useCallback(
|
||||
async (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
const prev = useHidStore.getState();
|
||||
let code = e.code;
|
||||
const key = e.key;
|
||||
|
||||
// if (document.activeElement?.id !== "videoFocusTrap") {
|
||||
// console.log("KEYUP: Not focusing on the video", document.activeElement);
|
||||
// return;
|
||||
// }
|
||||
console.log(document.activeElement);
|
||||
|
||||
setIsNumLockActive(e.getModifierState("NumLock"));
|
||||
setIsCapsLockActive(e.getModifierState("CapsLock"));
|
||||
setIsScrollLockActive(e.getModifierState("ScrollLock"));
|
||||
|
||||
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
|
||||
code = "Backquote";
|
||||
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
|
||||
code = "IntlBackslash";
|
||||
}
|
||||
|
||||
// Add the key to the active keys
|
||||
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
|
||||
|
||||
// Add the modifier to the active modifiers
|
||||
const newModifiers = handleModifierKeys(e, [
|
||||
...prev.activeModifiers,
|
||||
modifiers[code],
|
||||
]);
|
||||
|
||||
// When pressing the meta key + another key, the key will never trigger a keyup
|
||||
// event, so we need to clear the keys after a short delay
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
|
||||
if (e.metaKey) {
|
||||
setTimeout(() => {
|
||||
const prev = useHidStore.getState();
|
||||
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
||||
},
|
||||
[
|
||||
setIsNumLockActive,
|
||||
setIsCapsLockActive,
|
||||
setIsScrollLockActive,
|
||||
handleModifierKeys,
|
||||
sendKeyboardEvent,
|
||||
],
|
||||
);
|
||||
|
||||
const keyUpHandler = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
const prev = useHidStore.getState();
|
||||
|
||||
// if (document.activeElement?.id !== "videoFocusTrap") {
|
||||
// console.log("KEYUP: Not focusing on the video", document.activeElement);
|
||||
// return;
|
||||
// }
|
||||
|
||||
setIsNumLockActive(e.getModifierState("NumLock"));
|
||||
setIsCapsLockActive(e.getModifierState("CapsLock"));
|
||||
setIsScrollLockActive(e.getModifierState("ScrollLock"));
|
||||
|
||||
// Filtering out the key that was just released (keys[e.code])
|
||||
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
|
||||
|
||||
// Filter out the modifier that was just released
|
||||
const newModifiers = handleModifierKeys(
|
||||
e,
|
||||
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
|
||||
);
|
||||
|
||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
||||
},
|
||||
[
|
||||
setIsNumLockActive,
|
||||
setIsCapsLockActive,
|
||||
setIsScrollLockActive,
|
||||
handleModifierKeys,
|
||||
sendKeyboardEvent,
|
||||
],
|
||||
);
|
||||
|
||||
// Effect hooks
|
||||
useEffect(
|
||||
function setupKeyboardEvents() {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
document.addEventListener("keydown", keyDownHandler, { signal });
|
||||
document.addEventListener("keyup", keyUpHandler, { signal });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
window.clearKeys = () => sendKeyboardEvent([], []);
|
||||
window.addEventListener("blur", resetKeyboardState, { signal });
|
||||
document.addEventListener("visibilitychange", resetKeyboardState, { signal });
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
},
|
||||
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function setupVideoEventListeners() {
|
||||
let videoElmRefValue = null;
|
||||
if (!videoElm.current) return;
|
||||
videoElmRefValue = videoElm.current;
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal });
|
||||
videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal });
|
||||
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal });
|
||||
|
||||
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal });
|
||||
videoElmRefValue.addEventListener(
|
||||
"contextmenu",
|
||||
(e: MouseEvent) => e.preventDefault(),
|
||||
{ signal },
|
||||
);
|
||||
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
|
||||
|
||||
const local = resetMousePosition;
|
||||
window.addEventListener("blur", local, { signal });
|
||||
document.addEventListener("visibilitychange", local, { signal });
|
||||
|
||||
return () => {
|
||||
if (videoElmRefValue) abortController.abort();
|
||||
};
|
||||
},
|
||||
[mouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function updateVideoStream() {
|
||||
if (!mediaStream) return;
|
||||
if (!videoElm.current) return;
|
||||
if (peerConnection?.iceConnectionState !== "connected") return;
|
||||
|
||||
setTimeout(() => {
|
||||
if (videoElm?.current) {
|
||||
videoElm.current.srcObject = mediaStream;
|
||||
}
|
||||
}, 0);
|
||||
updateVideoSizeStore(videoElm.current);
|
||||
},
|
||||
[
|
||||
setVideoClientSize,
|
||||
setVideoSize,
|
||||
mediaStream,
|
||||
updateVideoSizeStore,
|
||||
peerConnection?.iceConnectionState,
|
||||
],
|
||||
);
|
||||
|
||||
// Focus trap management
|
||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const sidebarView = useUiStore(state => state.sidebarView);
|
||||
useEffect(() => {
|
||||
setTimeout(function () {
|
||||
if (["connection-stats", "system"].includes(sidebarView ?? "")) {
|
||||
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
|
||||
sendKeyboardEvent([], []);
|
||||
setDisableVideoFocusTrap(true);
|
||||
|
||||
// For some reason, the focus trap is not disabled immediately
|
||||
// so we need to blur the active element
|
||||
// (document.activeElement as HTMLElement)?.blur();
|
||||
console.log("Just disabled focus trap");
|
||||
} else {
|
||||
setDisableVideoFocusTrap(false);
|
||||
}
|
||||
}, 300);
|
||||
}, [sendKeyboardEvent, setDisableVideoFocusTrap, sidebarView]);
|
||||
|
||||
return (
|
||||
<div className="grid w-full h-full grid-rows-layout">
|
||||
<div className="min-h-[39.5px]">
|
||||
<fieldset disabled={peerConnectionState !== "connected"}>
|
||||
<Actionbar
|
||||
requestFullscreen={async () =>
|
||||
videoElm.current?.requestFullscreen({
|
||||
navigationUI: "show",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="h-full overflow-hidden">
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
className={cx(
|
||||
"absolute inset-0 bg-blue-50/40 dark:bg-slate-800/40 opacity-80",
|
||||
"[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]",
|
||||
"[background-position:0_0,10px_10px]",
|
||||
"[background-size:20px_20px]",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="relative flex-grow overflow-hidden">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="grid flex-grow overflow-hidden grid-rows-bodyFooter">
|
||||
<div className="relative flex items-center justify-center mx-4 my-2 overflow-hidden">
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<video
|
||||
ref={videoElm}
|
||||
autoPlay={true}
|
||||
controls={false}
|
||||
onPlaying={onVideoPlaying}
|
||||
onPlay={onVideoPlaying}
|
||||
muted={true}
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
controlsList="nofullscreen"
|
||||
className={cx(
|
||||
"outline-50 max-h-full max-w-full rounded-md object-contain transition-all duration-1000",
|
||||
{
|
||||
"cursor-none": settings.isCursorHidden,
|
||||
"opacity-0": isLoading || isConnectionError || hdmiError,
|
||||
"animate-slideUpFade border border-slate-800/30 dark:border-slate-300/20 opacity-0 shadow":
|
||||
isPlaying,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
style={{ animationDuration: "500ms" }}
|
||||
className="absolute inset-0 flex items-center justify-center opacity-0 pointer-events-none animate-slideUpFade"
|
||||
>
|
||||
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||
<LoadingOverlay show={isLoading} />
|
||||
<ConnectionErrorOverlay show={isConnectionError} />
|
||||
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VirtualKeyboard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<InfoBar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
ui/src/components/Xterm.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { Terminal } from "xterm";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
|
||||
import "xterm/css/xterm.css";
|
||||
import { useRTCStore, useUiStore } from "../hooks/stores";
|
||||
|
||||
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
|
||||
|
||||
// Add this debounce function at the top of the file
|
||||
function debounce(func: (...args: any[]) => void, wait: number) {
|
||||
let timeout: number | null = null;
|
||||
return (...args: any[]) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Terminal theme configuration
|
||||
const SOLARIZED_THEME = {
|
||||
background: "#0f172a", // Solarized base03
|
||||
foreground: "#839496", // Solarized base0
|
||||
cursor: "#93a1a1", // Solarized base1
|
||||
cursorAccent: "#002b36", // Solarized base03
|
||||
black: "#073642", // Solarized base02
|
||||
red: "#dc322f", // Solarized red
|
||||
green: "#859900", // Solarized green
|
||||
yellow: "#b58900", // Solarized yellow
|
||||
blue: "#268bd2", // Solarized blue
|
||||
magenta: "#d33682", // Solarized magenta
|
||||
cyan: "#2aa198", // Solarized cyan
|
||||
white: "#eee8d5", // Solarized base2
|
||||
brightBlack: "#002b36", // Solarized base03
|
||||
brightRed: "#cb4b16", // Solarized orange
|
||||
brightGreen: "#586e75", // Solarized base01
|
||||
brightYellow: "#657b83", // Solarized base00
|
||||
brightBlue: "#839496", // Solarized base0
|
||||
brightMagenta: "#6c71c4", // Solarized violet
|
||||
brightCyan: "#93a1a1", // Solarized base1
|
||||
brightWhite: "#fdf6e3", // Solarized base3
|
||||
} as const;
|
||||
|
||||
const TERMINAL_CONFIG = {
|
||||
theme: SOLARIZED_THEME,
|
||||
fontFamily: "'Fira Code', Menlo, Monaco, 'Courier New', monospace",
|
||||
fontSize: 13,
|
||||
allowProposedApi: true,
|
||||
scrollback: 1000,
|
||||
cursorBlink: true,
|
||||
smoothScrollDuration: 100,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: true,
|
||||
// Add these configurations:
|
||||
convertEol: true,
|
||||
linuxMode: false, // Disable Linux mode which might affect line endings
|
||||
} as const;
|
||||
|
||||
interface XTermProps {
|
||||
terminalChannel: RTCDataChannel | null;
|
||||
}
|
||||
|
||||
export function XTerm({ terminalChannel }: XTermProps) {
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const terminalElmRef = useRef<HTMLDivElement | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
|
||||
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
|
||||
useEffect(() => {
|
||||
setDisableKeyboardFocusTrap(true);
|
||||
|
||||
return () => {
|
||||
setDisableKeyboardFocusTrap(false);
|
||||
};
|
||||
}, [setDisableKeyboardFocusTrap]);
|
||||
|
||||
const initializeTerminalAddons = (term: Terminal) => {
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new ClipboardAddon());
|
||||
term.loadAddon(new Unicode11Addon());
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.unicode.activeVersion = "11";
|
||||
|
||||
if (isWebGl2Supported) {
|
||||
const webGl2Addon = new WebglAddon();
|
||||
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
|
||||
term.loadAddon(webGl2Addon);
|
||||
}
|
||||
|
||||
return fitAddon;
|
||||
};
|
||||
|
||||
const setupTerminalChannel = (
|
||||
term: Terminal,
|
||||
channel: RTCDataChannel,
|
||||
abortController: AbortController,
|
||||
) => {
|
||||
channel.onopen = () => {
|
||||
// Handle terminal input
|
||||
term.onData(data => {
|
||||
if (channel.readyState === "open") {
|
||||
channel.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle terminal output
|
||||
channel.addEventListener(
|
||||
"message",
|
||||
(event: MessageEvent) => {
|
||||
term.write(new Uint8Array(event.data));
|
||||
},
|
||||
{ signal: abortController.signal },
|
||||
);
|
||||
|
||||
// Send initial terminal size
|
||||
if (channel.readyState === "open") {
|
||||
channel.send(JSON.stringify({ rows: term.rows, cols: term.cols }));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!terminalElmRef.current) return;
|
||||
|
||||
// Ensure the container has dimensions before initializing
|
||||
if (!terminalElmRef.current.offsetHeight || !terminalElmRef.current.offsetWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
const term = new Terminal(TERMINAL_CONFIG);
|
||||
const fitAddon = initializeTerminalAddons(term);
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Setup escape key handler
|
||||
term.onKey(e => {
|
||||
const { domEvent } = e;
|
||||
if (domEvent.key === "Escape") {
|
||||
setEnableTerminal(false);
|
||||
setDisableKeyboardFocusTrap(false);
|
||||
domEvent.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
let elm: HTMLDivElement | null = terminalElmRef.current;
|
||||
// Initialize terminal
|
||||
setTimeout(() => {
|
||||
if (elm) {
|
||||
console.log("opening terminal");
|
||||
term.open(elm);
|
||||
fitAddon.fit();
|
||||
}
|
||||
}, 800);
|
||||
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// Setup resize handling
|
||||
const debouncedResizeHandler = debounce(() => fitAddon.fit(), 100);
|
||||
const resizeObserver = new ResizeObserver(debouncedResizeHandler);
|
||||
resizeObserver.observe(terminalElmRef.current);
|
||||
|
||||
// Focus terminal after a short delay
|
||||
setTimeout(() => {
|
||||
term.focus();
|
||||
terminalElmRef.current?.focus();
|
||||
}, 500);
|
||||
|
||||
// Setup terminal channel if available
|
||||
const channel = peerConnection?.createDataChannel("terminal");
|
||||
if (channel) {
|
||||
setupTerminalChannel(term, channel, abortController);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
abortController.abort();
|
||||
term.dispose();
|
||||
elm = null;
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [peerConnection, setDisableKeyboardFocusTrap, setEnableTerminal, terminalChannel]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full" ref={containerRef}>
|
||||
<div
|
||||
className="w-full h-full terminal-container"
|
||||
ref={terminalElmRef}
|
||||
style={{ display: "flex", minHeight: "100%" }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
306
ui/src/components/popovers/MountPopover.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { Button } from "@components/Button";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import Card, { GridCard } from "@components/Card";
|
||||
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { useMemo, forwardRef, useEffect, useCallback } from "react";
|
||||
import { formatters } from "@/utils";
|
||||
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
|
||||
import { SectionHeader } from "@components/SectionHeader";
|
||||
import {
|
||||
LuArrowUpFromLine,
|
||||
LuCheckCheck,
|
||||
LuLink,
|
||||
LuPlus,
|
||||
LuRadioReceiver,
|
||||
} from "react-icons/lu";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "../../notifications";
|
||||
import MountMediaModal from "../MountMediaDialog";
|
||||
import { useClose } from "@headlessui/react";
|
||||
|
||||
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
||||
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
|
||||
const [send] = useJsonRpc();
|
||||
const {
|
||||
remoteVirtualMediaState,
|
||||
isMountMediaDialogOpen,
|
||||
setModalView,
|
||||
setIsMountMediaDialogOpen,
|
||||
setRemoteVirtualMediaState,
|
||||
} = useMountMediaStore();
|
||||
|
||||
const bytesSentPerSecond = useMemo(() => {
|
||||
if (diskDataChannelStats.size < 2) return null;
|
||||
|
||||
const secondLastItem =
|
||||
Array.from(diskDataChannelStats)[diskDataChannelStats.size - 2];
|
||||
const lastItem = Array.from(diskDataChannelStats)[diskDataChannelStats.size - 1];
|
||||
|
||||
if (!secondLastItem || !lastItem) return 0;
|
||||
|
||||
const lastTime = lastItem[0];
|
||||
const secondLastTime = secondLastItem[0];
|
||||
const timeDelta = lastTime - secondLastTime;
|
||||
|
||||
const lastBytesSent = lastItem[1].bytesSent;
|
||||
const secondLastBytesSent = secondLastItem[1].bytesSent;
|
||||
const bytesDelta = lastBytesSent - secondLastBytesSent;
|
||||
|
||||
return bytesDelta / timeDelta;
|
||||
}, [diskDataChannelStats]);
|
||||
|
||||
const syncRemoteVirtualMediaState = useCallback(() => {
|
||||
send("getVirtualMediaState", {}, response => {
|
||||
if ("error" in response) {
|
||||
notifications.error(
|
||||
`Failed to get virtual media state: ${response.error.message}`,
|
||||
);
|
||||
} else {
|
||||
setRemoteVirtualMediaState(response.result as unknown as RemoteVirtualMediaState);
|
||||
}
|
||||
});
|
||||
}, [send, setRemoteVirtualMediaState]);
|
||||
|
||||
const handleUnmount = () => {
|
||||
send("unmountImage", {}, response => {
|
||||
if ("error" in response) {
|
||||
notifications.error(`Failed to unmount image: ${response.error.message}`);
|
||||
} else {
|
||||
syncRemoteVirtualMediaState();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderGridCardContent = () => {
|
||||
if (!remoteVirtualMediaState) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="inline-block">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
|
||||
No mounted media
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
|
||||
Add a file to get started
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { source, filename, size, url, path } = remoteVirtualMediaState;
|
||||
|
||||
switch (source) {
|
||||
case "WebRTC":
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<LuCheckCheck className="h-5 text-green-500" />
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from Browser</h3>
|
||||
</div>
|
||||
<Card className="w-auto px-2 py-1">
|
||||
<div className="w-full text-sm text-black truncate dark:text-white">
|
||||
{formatters.truncateMiddle(filename, 50)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="flex flex-col items-center my-2 gap-y-2">
|
||||
<div className="w-full text-sm text-slate-900 dark:text-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{formatters.bytes(size ?? 0)}</span>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<LuArrowUpFromLine className="h-4 text-blue-700 dark:text-blue-500" strokeWidth={2} />
|
||||
<span>
|
||||
{bytesSentPerSecond !== null
|
||||
? `${formatters.bytes(bytesSentPerSecond)}/s`
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "HTTP":
|
||||
return (
|
||||
<div className="">
|
||||
<div className="inline-block mb-0">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<LuLink className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from URL</h3>
|
||||
<p className="text-sm truncate text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(url, 55)}</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
|
||||
</div>
|
||||
);
|
||||
case "Storage":
|
||||
return (
|
||||
<div className="">
|
||||
<div className="inline-block mb-0">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<LuRadioReceiver className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">Mounted from JetKVM Storage</h3>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(path, 50)}</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const close = useClose();
|
||||
|
||||
useEffect(() => {
|
||||
syncRemoteVirtualMediaState();
|
||||
}, [syncRemoteVirtualMediaState, isMountMediaDialogOpen]);
|
||||
|
||||
return (
|
||||
<GridCard>
|
||||
<div className="p-4 py-3 space-y-4">
|
||||
<div ref={ref} className="grid h-full grid-rows-headerBody">
|
||||
<div className="h-full space-y-4 ">
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
title="Virtual Media"
|
||||
description="Mount an image to boot from or install an operating system."
|
||||
/>
|
||||
|
||||
{remoteVirtualMediaState?.source === "WebRTC" ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
|
||||
<ExclamationTriangleIcon className="h-4 text-yellow-500" />
|
||||
<div className="flex items-center w-full text-black">
|
||||
<div>Closing this tab will unmount the image</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="space-y-2 opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
}}
|
||||
>
|
||||
<div className="block select-none">
|
||||
<div className="group">
|
||||
<Card>
|
||||
<div className="w-full px-4 py-8">
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
{renderGridCardContent()}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{remoteVirtualMediaState ? (
|
||||
<div className="flex items-center justify-between text-xs select-none">
|
||||
<div className="text-white select-none dark:text-slate-300">
|
||||
<span>Mounted as</span>{" "}
|
||||
<span className="font-semibold">
|
||||
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text="Close"
|
||||
onClick={() => {
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Unmount"
|
||||
LeadingIcon={({ className }) => (
|
||||
<svg
|
||||
className={`${className} h-2.5 w-2.5 shrink-0`}
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_3137_1186)">
|
||||
<path
|
||||
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M10 7.49976H0V9.22453H10V7.49976Z" fill="currentColor" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3137_1186">
|
||||
<rect width="10" height="10" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)}
|
||||
onClick={handleUnmount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MountMediaModal
|
||||
open={isMountMediaDialogOpen}
|
||||
setOpen={setIsMountMediaDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!remoteVirtualMediaState && (
|
||||
<div
|
||||
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text="Close"
|
||||
onClick={() => {
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Add New Media"
|
||||
onClick={() => {
|
||||
setModalView("mode");
|
||||
setIsMountMediaDialogOpen(true);
|
||||
}}
|
||||
LeadingIcon={LuPlus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
});
|
||||
|
||||
MountPopopover.displayName = "MountSidebarRoute";
|
||||
|
||||
export default MountPopopover;
|
||||
164
ui/src/components/popovers/PasteModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Button } from "@components/Button";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { TextAreaWithLabel } from "@components/TextArea";
|
||||
import { SectionHeader } from "@components/SectionHeader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
|
||||
import notifications from "../../notifications";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { LuCornerDownLeft } from "react-icons/lu";
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
||||
import { useClose } from "@headlessui/react";
|
||||
import { chars, keys, modifiers } from "@/keyboardMappings";
|
||||
|
||||
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
||||
return { keys, modifier };
|
||||
};
|
||||
|
||||
export default function PasteModal() {
|
||||
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
|
||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
|
||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||
const close = useClose();
|
||||
|
||||
const onCancelPasteMode = useCallback(() => {
|
||||
setPasteMode(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
setInvalidChars([]);
|
||||
}, [setDisableVideoFocusTrap, setPasteMode]);
|
||||
|
||||
const onConfirmPaste = useCallback(async () => {
|
||||
setPasteMode(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
|
||||
|
||||
const text = TextAreaRef.current.value;
|
||||
|
||||
try {
|
||||
for (const char of text) {
|
||||
const { key, shift } = chars[char] ?? {};
|
||||
if (!key) continue;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
send(
|
||||
"keyboardReport",
|
||||
hidKeyboardPayload([keys[key]], shift ? modifiers["ShiftLeft"] : 0),
|
||||
params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
send("keyboardReport", hidKeyboardPayload([], 0), params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (TextAreaRef.current) {
|
||||
TextAreaRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GridCard>
|
||||
<div className="p-4 py-3 space-y-4">
|
||||
<div className="grid h-full grid-rows-headerBody">
|
||||
<div className="h-full space-y-4">
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
title="Paste text"
|
||||
description="Paste text from your client to the remote host"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="space-y-2 opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="w-full" onKeyUp={e => e.stopPropagation()}>
|
||||
<TextAreaWithLabel
|
||||
ref={TextAreaRef}
|
||||
label="Paste from host"
|
||||
rows={4}
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
onConfirmPaste();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancelPasteMode();
|
||||
}
|
||||
}}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
const invalidChars = [
|
||||
...new Set(
|
||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||
[...new Intl.Segmenter().segment(value)]
|
||||
.map(x => x.segment)
|
||||
.filter(char => !chars[char]),
|
||||
),
|
||||
];
|
||||
|
||||
setInvalidChars(invalidChars);
|
||||
}}
|
||||
/>
|
||||
|
||||
{invalidChars.length > 0 && (
|
||||
<div className="flex items-center mt-2 gap-x-2">
|
||||
<ExclamationCircleIcon className="w-4 h-4 text-red-500 dark:text-red-400" />
|
||||
<span className="text-xs text-red-500 dark:text-red-400">
|
||||
The following characters won't be pasted:{" "}
|
||||
{invalidChars.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-end opacity-0 animate-fadeIn gap-x-2"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text="Cancel"
|
||||
onClick={() => {
|
||||
onCancelPasteMode();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Confirm Paste"
|
||||
onClick={onConfirmPaste}
|
||||
LeadingIcon={LuCornerDownLeft}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
104
ui/src/components/popovers/WakeOnLan/AddDeviceForm.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { useState, useRef } from "react";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import { Button } from "../../Button";
|
||||
import { LuArrowLeft } from "react-icons/lu";
|
||||
|
||||
interface AddDeviceFormProps {
|
||||
onAddDevice: (name: string, macAddress: string) => void;
|
||||
setShowAddForm: (show: boolean) => void;
|
||||
errorMessage: string | null;
|
||||
setErrorMessage: (errorMessage: string | null) => void;
|
||||
}
|
||||
|
||||
export default function AddDeviceForm({
|
||||
setShowAddForm,
|
||||
onAddDevice,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
}: AddDeviceFormProps) {
|
||||
const [isDeviceNameValid, setIsDeviceNameValid] = useState<boolean>(false);
|
||||
const [isMacAddressValid, setIsMacAddressValid] = useState<boolean>(false);
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const macInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="space-y-4 opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.5s",
|
||||
animationFillMode: "forwards",
|
||||
}}
|
||||
>
|
||||
<InputFieldWithLabel
|
||||
ref={nameInputRef}
|
||||
placeholder="Plex Media Server"
|
||||
label="Device Name"
|
||||
required
|
||||
onChange={e => {
|
||||
setIsDeviceNameValid(e.target.validity.valid);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
maxLength={30}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
ref={macInputRef}
|
||||
placeholder="00:b0:d0:63:c2:26"
|
||||
label="MAC Address"
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
required
|
||||
pattern="^([0-9a-fA-F][0-9a-fA-F]:){5}([0-9a-fA-F][0-9a-fA-F])$"
|
||||
error={errorMessage}
|
||||
onChange={e => {
|
||||
setIsMacAddressValid(e.target.validity.valid);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
minLength={17}
|
||||
maxLength={17}
|
||||
onKeyDown={e => {
|
||||
if (isMacAddressValid || isDeviceNameValid) {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
const deviceName = nameInputRef.current?.value || "";
|
||||
const macAddress = macInputRef.current?.value || "";
|
||||
onAddDevice(deviceName, macAddress);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setShowAddForm(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Back"
|
||||
LeadingIcon={LuArrowLeft}
|
||||
onClick={() => setShowAddForm(false)}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Save Device"
|
||||
disabled={!isDeviceNameValid || !isMacAddressValid}
|
||||
onClick={() => {
|
||||
const deviceName = nameInputRef.current?.value || "";
|
||||
const macAddress = macInputRef.current?.value || "";
|
||||
onAddDevice(deviceName, macAddress);
|
||||
}}
|
||||
LeadingIcon={LuPlus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
ui/src/components/popovers/WakeOnLan/DeviceList.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Button } from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { FieldError } from "@components/InputField";
|
||||
import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
|
||||
|
||||
export interface StoredDevice {
|
||||
name: string;
|
||||
macAddress: string;
|
||||
}
|
||||
|
||||
interface DeviceListProps {
|
||||
storedDevices: StoredDevice[];
|
||||
errorMessage: string | null;
|
||||
onSendMagicPacket: (macAddress: string) => void;
|
||||
onDeleteDevice: (index: number) => void;
|
||||
onCancelWakeOnLanModal: () => void;
|
||||
setShowAddForm: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export default function DeviceList({
|
||||
storedDevices,
|
||||
errorMessage,
|
||||
onSendMagicPacket,
|
||||
onDeleteDevice,
|
||||
onCancelWakeOnLanModal,
|
||||
setShowAddForm,
|
||||
}: DeviceListProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="opacity-0 animate-fadeIn">
|
||||
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
|
||||
{storedDevices.map((device, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 gap-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">{device?.name}</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{device.macAddress?.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMessage && <FieldError error={errorMessage} />}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Wake"
|
||||
LeadingIcon={LuSend}
|
||||
onClick={() => onSendMagicPacket(device.macAddress)}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="danger"
|
||||
LeadingIcon={LuTrash2}
|
||||
onClick={() => onDeleteDevice(index)}
|
||||
aria-label="Delete device"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<div
|
||||
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text="Close"
|
||||
onClick={onCancelWakeOnLanModal}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Add New Device"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
LeadingIcon={LuPlus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
ui/src/components/popovers/WakeOnLan/EmptyStateCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import Card from "@components/Card";
|
||||
import { PlusCircleIcon } from "@heroicons/react/16/solid";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import { Button } from "../../Button";
|
||||
|
||||
export default function EmptyStateCard({
|
||||
onCancelWakeOnLanModal,
|
||||
setShowAddForm,
|
||||
}: {
|
||||
onCancelWakeOnLanModal: () => void;
|
||||
setShowAddForm: (show: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 select-none">
|
||||
<Card className="opacity-0 animate-fadeIn">
|
||||
<div className="flex items-center justify-center py-8 text-center">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="inline-block">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
|
||||
No devices added
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
|
||||
Add a device to start using Wake-on-LAN
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div
|
||||
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
}}
|
||||
>
|
||||
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Add New Device"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
LeadingIcon={LuPlus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
ui/src/components/popovers/WakeOnLan/Index.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { GridCard } from "@components/Card";
|
||||
import { SectionHeader } from "@components/SectionHeader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
||||
import notifications from "@/notifications";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useClose } from "@headlessui/react";
|
||||
import EmptyStateCard from "./EmptyStateCard";
|
||||
import DeviceList, { StoredDevice } from "./DeviceList";
|
||||
import AddDeviceForm from "./AddDeviceForm";
|
||||
|
||||
export default function WakeOnLanModal() {
|
||||
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
const close = useClose();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const onCancelWakeOnLanModal = useCallback(() => {
|
||||
close();
|
||||
setDisableFocusTrap(false);
|
||||
}, [close, setDisableFocusTrap]);
|
||||
|
||||
const onSendMagicPacket = useCallback(
|
||||
(macAddress: string) => {
|
||||
setErrorMessage(null);
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
|
||||
send("sendWOLMagicPacket", { macAddress }, resp => {
|
||||
if ("error" in resp) {
|
||||
const isInvalid = resp.error.data?.includes("invalid MAC address");
|
||||
if (isInvalid) {
|
||||
setErrorMessage("Invalid MAC address");
|
||||
} else {
|
||||
setErrorMessage("Failed to send Magic Packet");
|
||||
}
|
||||
} else {
|
||||
notifications.success("Magic Packet sent successfully");
|
||||
setDisableFocusTrap(false);
|
||||
close();
|
||||
}
|
||||
});
|
||||
},
|
||||
[close, rpcDataChannel?.readyState, send, setDisableFocusTrap],
|
||||
);
|
||||
|
||||
const syncStoredDevices = useCallback(() => {
|
||||
send("getWakeOnLanDevices", {}, resp => {
|
||||
if ("result" in resp) {
|
||||
setStoredDevices(resp.result as StoredDevice[]);
|
||||
} else {
|
||||
console.error("Failed to load Wake-on-LAN devices:", resp.error);
|
||||
}
|
||||
});
|
||||
}, [send, setStoredDevices]);
|
||||
|
||||
// Load stored devices from the backend
|
||||
useEffect(() => {
|
||||
syncStoredDevices();
|
||||
}, [syncStoredDevices]);
|
||||
|
||||
const onDeleteDevice = useCallback(
|
||||
(index: number) => {
|
||||
const updatedDevices = storedDevices.filter((_, i) => i !== index);
|
||||
|
||||
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
|
||||
if ("error" in resp) {
|
||||
console.error("Failed to update Wake-on-LAN devices:", resp.error);
|
||||
} else {
|
||||
syncStoredDevices();
|
||||
}
|
||||
});
|
||||
},
|
||||
[storedDevices, send, syncStoredDevices],
|
||||
);
|
||||
|
||||
const onAddDevice = useCallback(
|
||||
(name: string, macAddress: string) => {
|
||||
if (!name || !macAddress) return;
|
||||
const updatedDevices = [...storedDevices, { name, macAddress }];
|
||||
console.log("updatedDevices", updatedDevices);
|
||||
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
|
||||
if ("error" in resp) {
|
||||
console.error("Failed to add Wake-on-LAN device:", resp.error);
|
||||
setAddDeviceErrorMessage("Failed to add device");
|
||||
} else {
|
||||
setShowAddForm(false);
|
||||
syncStoredDevices();
|
||||
}
|
||||
});
|
||||
},
|
||||
[send, storedDevices, syncStoredDevices],
|
||||
);
|
||||
|
||||
return (
|
||||
<GridCard>
|
||||
<div className="p-4 py-3 space-y-4">
|
||||
<div className="grid h-full grid-rows-headerBody">
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
title="Wake On LAN"
|
||||
description="Send a Magic Packet to wake up a remote device."
|
||||
/>
|
||||
|
||||
{showAddForm ? (
|
||||
<AddDeviceForm
|
||||
setShowAddForm={setShowAddForm}
|
||||
errorMessage={addDeviceErrorMessage}
|
||||
setErrorMessage={setAddDeviceErrorMessage}
|
||||
onAddDevice={onAddDevice}
|
||||
/>
|
||||
) : storedDevices.length === 0 ? (
|
||||
<EmptyStateCard
|
||||
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
|
||||
setShowAddForm={setShowAddForm}
|
||||
/>
|
||||
) : (
|
||||
<DeviceList
|
||||
storedDevices={storedDevices}
|
||||
errorMessage={errorMessage}
|
||||
onSendMagicPacket={onSendMagicPacket}
|
||||
onDeleteDevice={onDeleteDevice}
|
||||
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
|
||||
setShowAddForm={setShowAddForm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
258
ui/src/components/sidebar/connectionStats.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import SidebarHeader from "@components/SidebarHeader";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { useEffect } from "react";
|
||||
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
||||
import StatChart from "@components/StatChart";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
|
||||
function createChartArray<T, K extends keyof T>(
|
||||
stream: Map<number, T>,
|
||||
metric: K,
|
||||
): { date: number; stat: T[K] | null }[] {
|
||||
const stat = Array.from(stream).map(([key, stats]) => {
|
||||
return { date: key, stat: stats[metric] };
|
||||
});
|
||||
|
||||
// Sort the dates to ensure they are in chronological order
|
||||
const sortedStat = stat.map(x => x.date).sort((a, b) => a - b);
|
||||
|
||||
// Determine the earliest statistic date
|
||||
const earliestStat = sortedStat[0];
|
||||
|
||||
// Current time in seconds since the Unix epoch
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Determine the starting point for the chart data
|
||||
const firstChartDate = earliestStat ? Math.min(earliestStat, now - 120) : now - 120;
|
||||
|
||||
// Generate the chart array for the range between 'firstChartDate' and 'now'
|
||||
return Array.from({ length: now - firstChartDate }, (_, i) => {
|
||||
const currentDate = firstChartDate + i;
|
||||
return {
|
||||
date: currentDate,
|
||||
// Find the statistic for 'currentDate', or use the last known statistic if none exists for that date
|
||||
stat: stat.find(x => x.date === currentDate)?.stat ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function ConnectionStatsSidebar () {
|
||||
const setModalView = useUiStore(state => state.setModalView);
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setModalView(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [setModalView]);
|
||||
|
||||
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
|
||||
|
||||
const candidatePairStats = useRTCStore(state => state.candidatePairStats);
|
||||
const setSidebarView = useUiStore(state => state.setSidebarView);
|
||||
|
||||
function isMetricSupported<T, K extends keyof T>(
|
||||
stream: Map<number, T>,
|
||||
metric: K,
|
||||
): boolean {
|
||||
return Array.from(stream).some(([, stat]) => stat[metric] !== undefined);
|
||||
}
|
||||
|
||||
const appendInboundRtpStats = useRTCStore(state => state.appendInboundRtpStats);
|
||||
const appendIceCandidatePair = useRTCStore(state => state.appendCandidatePairStats);
|
||||
const appendDiskDataChannelStats = useRTCStore(
|
||||
state => state.appendDiskDataChannelStats,
|
||||
);
|
||||
const appendLocalCandidateStats = useRTCStore(state => state.appendLocalCandidateStats);
|
||||
const appendRemoteCandidateStats = useRTCStore(
|
||||
state => state.appendRemoteCandidateStats,
|
||||
);
|
||||
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
const mediaStream = useRTCStore(state => state.mediaStream);
|
||||
const sidebarView = useUiStore(state => state.sidebarView);
|
||||
|
||||
useInterval(function collectWebRTCStats() {
|
||||
(async () => {
|
||||
if (!mediaStream) return;
|
||||
const videoTrack = mediaStream.getVideoTracks()[0];
|
||||
if (!videoTrack) return;
|
||||
const stats = await peerConnection?.getStats();
|
||||
let successfulLocalCandidateId: string | null = null;
|
||||
let successfulRemoteCandidateId: string | null = null;
|
||||
|
||||
stats?.forEach(report => {
|
||||
if (report.type === "inbound-rtp") {
|
||||
appendInboundRtpStats(report);
|
||||
} else if (report.type === "candidate-pair" && report.nominated) {
|
||||
if (report.state === "succeeded") {
|
||||
successfulLocalCandidateId = report.localCandidateId;
|
||||
successfulRemoteCandidateId = report.remoteCandidateId;
|
||||
}
|
||||
|
||||
appendIceCandidatePair(report);
|
||||
} else if (report.type === "local-candidate") {
|
||||
// We only want to append the local candidate stats that were used in nominated candidate pair
|
||||
if (successfulLocalCandidateId === report.id) {
|
||||
appendLocalCandidateStats(report);
|
||||
}
|
||||
} else if (report.type === "remote-candidate") {
|
||||
if (successfulRemoteCandidateId === report.id) {
|
||||
appendRemoteCandidateStats(report);
|
||||
}
|
||||
} else if (report.type === "data-channel" && report.label === "disk") {
|
||||
appendDiskDataChannelStats(report);
|
||||
}
|
||||
});
|
||||
})();
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
<div className="grid h-full shadow-sm grid-rows-headerBody">
|
||||
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
|
||||
<div className="h-full px-4 py-2 pb-8 space-y-4 overflow-y-scroll bg-white dark:bg-slate-900">
|
||||
<div className="space-y-4">
|
||||
{/*
|
||||
The entire sidebar component is always rendered, with a display none when not visible
|
||||
The charts below, need a height and width, otherwise they throw. So simply don't render them unless the thing is visible
|
||||
*/}
|
||||
{sidebarView === "connection-stats" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-black dark:text-white">
|
||||
Packets Lost
|
||||
</h2>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
Number of data packets lost during transmission.
|
||||
</p>
|
||||
</div>
|
||||
<GridCard>
|
||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||
{inboundRtpStats.size === 0 ? (
|
||||
<div className="flex flex-col items-center space-y-1 ">
|
||||
<p className="text-slate-700">Waiting for data...</p>
|
||||
</div>
|
||||
) : isMetricSupported(inboundRtpStats, "packetsLost") ? (
|
||||
<StatChart
|
||||
data={createChartArray(inboundRtpStats, "packetsLost")}
|
||||
domain={[0, 100]}
|
||||
unit=" packets"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-1 ">
|
||||
<p className="text-black">Metric not supported</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-black dark:text-white">
|
||||
Round-Trip Time
|
||||
</h2>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
Time taken for data to travel from source to destination and back
|
||||
</p>
|
||||
</div>
|
||||
<GridCard>
|
||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||
{inboundRtpStats.size === 0 ? (
|
||||
<div className="flex flex-col items-center space-y-1 ">
|
||||
<p className="text-slate-700">Waiting for data...</p>
|
||||
</div>
|
||||
) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? (
|
||||
<StatChart
|
||||
data={createChartArray(
|
||||
candidatePairStats,
|
||||
"currentRoundTripTime",
|
||||
).map(x => {
|
||||
return {
|
||||
date: x.date,
|
||||
stat: x.stat ? Math.round(x.stat * 1000) : null,
|
||||
};
|
||||
})}
|
||||
domain={[0, 600]}
|
||||
unit=" ms"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-1 ">
|
||||
<p className="text-black">Metric not supported</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-black dark:text-white">
|
||||
Jitter
|
||||
</h2>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
Variation in packet delay, affecting video smoothness.{" "}
|
||||
</p>
|
||||
</div>
|
||||
<GridCard>
|
||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||
{inboundRtpStats.size === 0 ? (
|
||||
<div className="flex flex-col items-center space-y-1 ">
|
||||
<p className="text-slate-700">Waiting for data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<StatChart
|
||||
data={createChartArray(inboundRtpStats, "jitter").map(x => {
|
||||
return {
|
||||
date: x.date,
|
||||
stat: x.stat ? Math.round(x.stat * 1000) : null,
|
||||
};
|
||||
})}
|
||||
domain={[0, 300]}
|
||||
unit=" ms"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-black dark:text-white">
|
||||
Frames per second
|
||||
</h2>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
Number of video frames displayed per second.
|
||||
</p>
|
||||
</div>
|
||||
<GridCard>
|
||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||
{inboundRtpStats.size === 0 ? (
|
||||
<div className="flex flex-col items-center space-y-1 ">
|
||||
<p className="text-slate-700">Waiting for data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<StatChart
|
||||
data={createChartArray(inboundRtpStats, "framesPerSecond").map(
|
||||
x => {
|
||||
return {
|
||||
date: x.date,
|
||||
stat: x.stat ? x.stat : null,
|
||||
};
|
||||
},
|
||||
)}
|
||||
domain={[0, 80]}
|
||||
unit=" fps"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
899
ui/src/components/sidebar/settings.tsx
Normal file
@@ -0,0 +1,899 @@
|
||||
import SidebarHeader from "@components/SidebarHeader";
|
||||
import {
|
||||
useLocalAuthModalStore,
|
||||
useSettingsStore,
|
||||
useUiStore,
|
||||
useUpdateStore,
|
||||
} from "@/hooks/stores";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
import { TextAreaWithLabel } from "@components/TextArea";
|
||||
import { SectionHeader } from "@components/SectionHeader";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { cx } from "@/cva.config";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { isOnDevice } from "@/main";
|
||||
import PointingFinger from "@/assets/pointing-finger.svg";
|
||||
import MouseIcon from "@/assets/mouse-icon.svg";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { SelectMenuBasic } from "../SelectMenuBasic";
|
||||
import { SystemVersionInfo } from "@components/UpdateDialog";
|
||||
import notifications from "@/notifications";
|
||||
import api from "../../api";
|
||||
import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog";
|
||||
import { LocalDevice } from "@routes/devices.$id";
|
||||
import { useRevalidator } from "react-router-dom";
|
||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function SettingsItem({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description: string | React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className={cx("flex items-center justify-between gap-x-4 rounded", className)}>
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">{title}</h3>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">{description}</p>
|
||||
</div>
|
||||
{children ? <div>{children}</div> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultEdid =
|
||||
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
|
||||
const edids = [
|
||||
{
|
||||
value: defaultEdid,
|
||||
label: "JetKVM Default",
|
||||
},
|
||||
{
|
||||
value:
|
||||
"00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
|
||||
label: "Acer B246WL, 1920x1200",
|
||||
},
|
||||
{
|
||||
value:
|
||||
"00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC",
|
||||
label: "ASUS PA248QV, 1920x1200",
|
||||
},
|
||||
{
|
||||
value:
|
||||
"00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F",
|
||||
label: "DELL D2721H, 1920x1080",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsSidebar() {
|
||||
const setSidebarView = useUiStore(state => state.setSidebarView);
|
||||
const settings = useSettingsStore();
|
||||
const [send] = useJsonRpc();
|
||||
const [streamQuality, setStreamQuality] = useState("1");
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const [devChannel, setDevChannel] = useState(false);
|
||||
const [jiggler, setJiggler] = useState(false);
|
||||
const [edid, setEdid] = useState<string | null>(null);
|
||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||
|
||||
const [isAdopted, setAdopted] = useState(false);
|
||||
const [deviceId, setDeviceId] = useState<string | null>(null);
|
||||
|
||||
const [sshKey, setSSHKey] = useState<string>("");
|
||||
const [localDevice, setLocalDevice] = useState<LocalDevice | null>(null);
|
||||
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hideCursor = useSettingsStore(state => state.isCursorHidden);
|
||||
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
|
||||
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
|
||||
|
||||
const [currentVersions, setCurrentVersions] = useState<{
|
||||
appVersion: string;
|
||||
systemVersion: string;
|
||||
} | null>(null);
|
||||
|
||||
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
||||
const getUsbEmulationState = useCallback(() => {
|
||||
send("getUsbEmulationState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setUsbEmulationEnabled(resp.result as boolean);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleUsbEmulationToggle = useCallback(
|
||||
(enabled: boolean) => {
|
||||
send("setUsbEmulationState", { enabled: enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUsbEmulationEnabled(enabled);
|
||||
getUsbEmulationState();
|
||||
});
|
||||
},
|
||||
[getUsbEmulationState, send],
|
||||
);
|
||||
|
||||
const getCloudState = useCallback(() => {
|
||||
send("getCloudState", {}, resp => {
|
||||
if ("error" in resp) return console.error(resp.error);
|
||||
const cloudState = resp.result as { connected: boolean };
|
||||
setAdopted(cloudState.connected);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const deregisterDevice = async () => {
|
||||
send("deregisterDevice", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
getCloudState();
|
||||
return;
|
||||
});
|
||||
};
|
||||
|
||||
const handleStreamQualityChange = (factor: string) => {
|
||||
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setStreamQuality(factor);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutoUpdateChange = (enabled: boolean) => {
|
||||
send("setAutoUpdateState", { enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAutoUpdate(enabled);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDevChannelChange = (enabled: boolean) => {
|
||||
send("setDevChannelState", { enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDevChannel(enabled);
|
||||
});
|
||||
};
|
||||
|
||||
const handleJigglerChange = (enabled: boolean) => {
|
||||
send("setJigglerState", { enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setJiggler(enabled);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEDIDChange = (newEdid: string) => {
|
||||
send("setEDID", { edid: newEdid }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the EDID value in the UI
|
||||
setEdid(newEdid);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSSHKeyChange = (newKey: string) => {
|
||||
setSSHKey(newKey);
|
||||
};
|
||||
|
||||
const handleDevModeChange = useCallback(
|
||||
(developerMode: boolean) => {
|
||||
send("setDevModeState", { enabled: developerMode }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDeveloperMode(developerMode);
|
||||
setTimeout(() => {
|
||||
sidebarRef.current?.scrollTo({ top: 5000, behavior: "smooth" });
|
||||
}, 0);
|
||||
});
|
||||
},
|
||||
[send, setDeveloperMode],
|
||||
);
|
||||
|
||||
const handleUpdateSSHKey = useCallback(() => {
|
||||
send("setSSHKeyState", { sshKey }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("SSH key updated successfully");
|
||||
});
|
||||
}, [send, sshKey]);
|
||||
|
||||
const { setIsUpdateDialogOpen, setModalView, otaState } = useUpdateStore();
|
||||
const handleCheckForUpdates = () => {
|
||||
if (otaState.updating) {
|
||||
setModalView("updating");
|
||||
setIsUpdateDialogOpen(true);
|
||||
} else {
|
||||
setModalView("loading");
|
||||
setIsUpdateDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getCloudState();
|
||||
|
||||
send("getDeviceID", {}, async resp => {
|
||||
if ("error" in resp) return console.error(resp.error);
|
||||
setDeviceId(resp.result as string);
|
||||
});
|
||||
|
||||
send("getJigglerState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setJiggler(resp.result as boolean);
|
||||
});
|
||||
|
||||
send("getAutoUpdateState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setAutoUpdate(resp.result as boolean);
|
||||
});
|
||||
|
||||
send("getDevChannelState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setDevChannel(resp.result as boolean);
|
||||
});
|
||||
|
||||
send("getStreamQualityFactor", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setStreamQuality(String(resp.result));
|
||||
});
|
||||
|
||||
send("getEDID", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const receivedEdid = resp.result as string;
|
||||
|
||||
const matchingEdid = edids.find(
|
||||
x => x.value.toLowerCase() === receivedEdid.toLowerCase(),
|
||||
);
|
||||
|
||||
if (matchingEdid) {
|
||||
// EDID is stored in uppercase in the UI
|
||||
setEdid(matchingEdid.value.toUpperCase());
|
||||
// Reset custom EDID value
|
||||
setCustomEdidValue(null);
|
||||
} else {
|
||||
setEdid("custom");
|
||||
setCustomEdidValue(receivedEdid);
|
||||
}
|
||||
});
|
||||
|
||||
send("getDevModeState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
const result = resp.result as { enabled: boolean };
|
||||
setDeveloperMode(result.enabled);
|
||||
});
|
||||
|
||||
send("getSSHKeyState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setSSHKey(resp.result as string);
|
||||
});
|
||||
|
||||
send("getUpdateStatus", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
const result = resp.result as SystemVersionInfo;
|
||||
setCurrentVersions({
|
||||
appVersion: result.local.appVersion,
|
||||
systemVersion: result.local.systemVersion,
|
||||
});
|
||||
});
|
||||
|
||||
send("getUsbEmulationState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setUsbEmulationEnabled(resp.result as boolean);
|
||||
});
|
||||
}, [getCloudState, send, setDeveloperMode, setHideCursor, setJiggler]);
|
||||
|
||||
const getDevice = useCallback(async () => {
|
||||
try {
|
||||
const status = await api
|
||||
.GET(`${import.meta.env.VITE_SIGNAL_API}/device`)
|
||||
.then(res => res.json() as Promise<LocalDevice>);
|
||||
setLocalDevice(status);
|
||||
} catch (error) {
|
||||
notifications.error("Failed to get authentication status");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { setModalView: setLocalAuthModalView } = useLocalAuthModalStore();
|
||||
const [isLocalAuthDialogOpen, setIsLocalAuthDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnDevice) getDevice();
|
||||
}, [getDevice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnDevice) return;
|
||||
// Refresh device status when the local auth dialog is closed
|
||||
if (!isLocalAuthDialogOpen) {
|
||||
getDevice();
|
||||
}
|
||||
}, [getDevice, isLocalAuthDialogOpen]);
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const [currentTheme, setCurrentTheme] = useState(() => {
|
||||
return localStorage.theme || "system";
|
||||
});
|
||||
|
||||
const handleThemeChange = useCallback((value: string) => {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (value === "system") {
|
||||
localStorage.removeItem("theme");
|
||||
// Check system preference
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(systemTheme);
|
||||
} else {
|
||||
localStorage.theme = value;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleResetConfig = useCallback(() => {
|
||||
send("resetConfig", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("Configuration reset to default successfully");
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid h-full shadow-sm grid-rows-headerBody"
|
||||
// Prevent the keyboard entries from propagating to the document where they are listened for and sent to the KVM
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
>
|
||||
<SidebarHeader title="Settings" setSidebarView={setSidebarView} />
|
||||
<div
|
||||
className="h-full px-4 py-2 space-y-4 overflow-y-scroll bg-white dark:bg-slate-900"
|
||||
ref={sidebarRef}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mt-2 gap-x-2">
|
||||
<SettingsItem
|
||||
title="Check for Updates"
|
||||
description={
|
||||
currentVersions ? (
|
||||
<>
|
||||
App: {currentVersions.appVersion}
|
||||
<br />
|
||||
System: {currentVersions.systemVersion}
|
||||
</>
|
||||
) : (
|
||||
"Loading current versions..."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Check for Updates"
|
||||
onClick={handleCheckForUpdates}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
<SectionHeader
|
||||
title="Mouse"
|
||||
description="Customize mouse behavior and interaction settings"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Hide Cursor"
|
||||
description="Hide the cursor when sending mouse movements"
|
||||
>
|
||||
<Checkbox
|
||||
checked={hideCursor}
|
||||
onChange={e => {
|
||||
setHideCursor(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="Jiggler"
|
||||
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
|
||||
>
|
||||
<Checkbox
|
||||
checked={jiggler}
|
||||
onChange={e => {
|
||||
handleJigglerChange(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem title="Modes" description="Choose the mouse input mode" />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="block group grow"
|
||||
onClick={() => console.log("Absolute mouse mode clicked")}
|
||||
>
|
||||
<GridCard>
|
||||
<div className="flex items-center px-4 py-3 group gap-x-4">
|
||||
<img
|
||||
className="w-6 shrink-0"
|
||||
src={PointingFinger}
|
||||
alt="Finger touching a screen"
|
||||
/>
|
||||
<div className="flex items-center justify-between grow">
|
||||
<div className="text-left">
|
||||
<h3 className="text-sm font-semibold text-black dark:text-white">
|
||||
Absolute
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
|
||||
Most convenient
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</button>
|
||||
<button
|
||||
className="block opacity-50 cursor-not-allowed group grow"
|
||||
disabled
|
||||
>
|
||||
<GridCard>
|
||||
<div className="flex items-center px-4 py-3 gap-x-4">
|
||||
<img className="w-6 shrink-0" src={MouseIcon} alt="Mouse icon" />
|
||||
<div className="flex items-center justify-between grow">
|
||||
<div className="text-left">
|
||||
<h3 className="text-sm font-semibold text-black dark:text-white">
|
||||
Relative
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
|
||||
Coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
<div className="pb-2 space-y-4">
|
||||
<SectionHeader
|
||||
title="Video"
|
||||
description="Configure display settings and EDID for optimal compatibility"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Stream Quality"
|
||||
description="Adjust the quality of the video stream"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={streamQuality}
|
||||
options={[
|
||||
{ value: "1", label: "High" },
|
||||
{ value: "0.5", label: "Medium" },
|
||||
{ value: "0.1", label: "Low" },
|
||||
]}
|
||||
onChange={e => handleStreamQualityChange(e.target.value)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="EDID"
|
||||
description="Adjust the EDID settings for the display"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
fullWidth
|
||||
value={customEdidValue ? "custom" : edid || "asd"}
|
||||
onChange={e => {
|
||||
if (e.target.value === "custom") {
|
||||
setEdid("custom");
|
||||
setCustomEdidValue("");
|
||||
} else {
|
||||
handleEDIDChange(e.target.value as string);
|
||||
}
|
||||
}}
|
||||
options={[...edids, { value: "custom", label: "Custom" }]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{customEdidValue !== null && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="Custom EDID"
|
||||
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
|
||||
/>
|
||||
<TextAreaWithLabel
|
||||
label="EDID File"
|
||||
placeholder="00F..."
|
||||
rows={3}
|
||||
value={customEdidValue}
|
||||
onChange={e => setCustomEdidValue(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-start gap-x-2">
|
||||
<Button
|
||||
size="MD"
|
||||
theme="primary"
|
||||
text="Set Custom EDID"
|
||||
onClick={() => handleEDIDChange(customEdidValue)}
|
||||
/>
|
||||
<Button
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="Restore to default"
|
||||
onClick={() => {
|
||||
setCustomEdidValue(null);
|
||||
handleEDIDChange(defaultEdid);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isOnDevice && (
|
||||
<>
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
<div className="pb-4 space-y-4">
|
||||
<SectionHeader
|
||||
title="JetKVM Cloud"
|
||||
description="Connect your device to the cloud for secure remote access and management"
|
||||
/>
|
||||
|
||||
<GridCard>
|
||||
<div className="flex items-start p-4 gap-x-4">
|
||||
<ShieldCheckIcon className="w-8 h-8 mt-1 text-blue-600 shrink-0 dark:text-blue-500" />
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
Cloud Security
|
||||
</h3>
|
||||
<div>
|
||||
<ul className="space-y-1 text-xs text-slate-700 dark:text-slate-300">
|
||||
<li>• End-to-end encryption using WebRTC (DTLS and SRTP)</li>
|
||||
<li>• Zero Trust security model</li>
|
||||
<li>• OIDC (OpenID Connect) authentication</li>
|
||||
<li>• All streams encrypted in transit</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-700 dark:text-slate-300">
|
||||
All cloud components are open-source and available on{" "}
|
||||
<a
|
||||
href="https://github.com/jetkvm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
<hr className="block w-full dark:border-slate-600" />
|
||||
|
||||
<div>
|
||||
<LinkButton
|
||||
to="https://jetkvm.com/docs/networking/remote-access"
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Learn about our cloud security"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
|
||||
{!isAdopted ? (
|
||||
<div>
|
||||
<LinkButton
|
||||
to={
|
||||
import.meta.env.VITE_CLOUD_APP +
|
||||
"/signup?deviceId=" +
|
||||
deviceId +
|
||||
`&returnTo=${location.href}adopt`
|
||||
}
|
||||
size="MD"
|
||||
theme="primary"
|
||||
text="Adopt KVM to Cloud account"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Your device is adopted to JetKVM Cloud
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="De-register from Cloud"
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
if (deviceId) {
|
||||
if (
|
||||
window.confirm(
|
||||
"Are you sure you want to de-register this device?",
|
||||
)
|
||||
) {
|
||||
deregisterDevice();
|
||||
}
|
||||
} else {
|
||||
notifications.error("No device ID available");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
{isOnDevice ? (
|
||||
<>
|
||||
<div className="pb-2 space-y-4">
|
||||
<SectionHeader
|
||||
title="Local Access"
|
||||
description="Manage the mode of local access to the device"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Authentication Mode"
|
||||
description={`Current mode: ${localDevice?.authMode === "password" ? "Password protected" : "No password"}`}
|
||||
>
|
||||
{localDevice?.authMode === "password" ? (
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Disable Protection"
|
||||
onClick={() => {
|
||||
setLocalAuthModalView("deletePassword");
|
||||
setIsLocalAuthDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Enable Password"
|
||||
onClick={() => {
|
||||
setLocalAuthModalView("createPassword");
|
||||
setIsLocalAuthDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SettingsItem>
|
||||
|
||||
{localDevice?.authMode === "password" && (
|
||||
<SettingsItem
|
||||
title="Change Password"
|
||||
description="Update your device access password"
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Change Password"
|
||||
onClick={() => {
|
||||
setLocalAuthModalView("updatePassword");
|
||||
setIsLocalAuthDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
</>
|
||||
) : null}
|
||||
<div className="pb-2 space-y-4">
|
||||
<SectionHeader
|
||||
title="Updates"
|
||||
description="Manage software updates and version information"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Auto Update"
|
||||
description="Automatically update the device to the latest version"
|
||||
>
|
||||
<Checkbox
|
||||
checked={autoUpdate}
|
||||
onChange={e => {
|
||||
handleAutoUpdateChange(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="Dev Channel Updates"
|
||||
description="Receive early updates from the development channel"
|
||||
>
|
||||
<Checkbox
|
||||
checked={devChannel}
|
||||
onChange={e => {
|
||||
handleDevChannelChange(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
|
||||
<SectionHeader
|
||||
title="Appearance"
|
||||
description="Customize the look and feel of the application"
|
||||
/>
|
||||
<SettingsItem title="Theme" description="Choose your preferred color theme">
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={currentTheme}
|
||||
options={[
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
]}
|
||||
onChange={e => {
|
||||
setCurrentTheme(e.target.value);
|
||||
handleThemeChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
<div className="pb-2 space-y-4">
|
||||
<SectionHeader
|
||||
title="Advanced"
|
||||
description="Access additional settings for troubleshooting and customization"
|
||||
/>
|
||||
|
||||
<div className="pb-4 space-y-4">
|
||||
<SettingsItem
|
||||
title="Developer Mode"
|
||||
description="Enable advanced features for developers"
|
||||
>
|
||||
<Checkbox
|
||||
checked={settings.developerMode}
|
||||
onChange={e => handleDevModeChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{settings.developerMode && (
|
||||
<div className="space-y-4">
|
||||
<TextAreaWithLabel
|
||||
label="SSH Public Key"
|
||||
value={sshKey || ""}
|
||||
rows={3}
|
||||
onChange={e => handleSSHKeyChange(e.target.value)}
|
||||
placeholder="Enter your SSH public key"
|
||||
/>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
The default SSH user is <strong>root</strong>.
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Update SSH Key"
|
||||
onClick={handleUpdateSSHKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SettingsItem
|
||||
title="Troubleshooting Mode"
|
||||
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
|
||||
>
|
||||
<Checkbox
|
||||
defaultChecked={settings.debugMode}
|
||||
onChange={e => {
|
||||
settings.setDebugMode(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{settings.debugMode && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="USB Emulation"
|
||||
description="Control the USB emulation state"
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text={
|
||||
usbEmulationEnabled
|
||||
? "Disable USB Emulation"
|
||||
: "Enable USB Emulation"
|
||||
}
|
||||
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</>
|
||||
)}
|
||||
{settings.debugMode && (
|
||||
<SettingsItem
|
||||
title="Reset Configuration"
|
||||
description="Reset the configuration file to its default state. This will log you out of the device."
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Reset Config"
|
||||
onClick={() => {
|
||||
handleResetConfig();
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LocalAuthPasswordDialog
|
||||
open={isLocalAuthDialogOpen}
|
||||
setOpen={x => {
|
||||
// Revalidate the current route to refresh the local device status and dependent UI components
|
||||
revalidator.revalidate();
|
||||
setIsLocalAuthDialogOpen(x);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
ui/src/cva.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "cva";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const { cva, cx, compose } = defineConfig({
|
||||
hooks: {
|
||||
onComplete: className => twMerge(className),
|
||||
},
|
||||
});
|
||||
530
ui/src/hooks/stores.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
// Utility function to append stats to a Map
|
||||
const appendStatToMap = <T extends { timestamp: number }>(
|
||||
stat: T,
|
||||
prevMap: Map<number, T>,
|
||||
maxEntries = 130,
|
||||
): Map<number, T> => {
|
||||
if (prevMap.size > maxEntries) {
|
||||
const firstKey = prevMap.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
prevMap.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
const date = Math.floor(stat.timestamp / 1000);
|
||||
const newStat = { ...prevMap.get(date), ...stat };
|
||||
return new Map(prevMap).set(date, newStat);
|
||||
};
|
||||
|
||||
// Constants and types
|
||||
export type AvailableSidebarViews = "system" | "connection-stats";
|
||||
export type AvailableModalViews = "connection-stats" | "settings";
|
||||
|
||||
export interface User {
|
||||
sub: string;
|
||||
email?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
}
|
||||
|
||||
interface UIState {
|
||||
sidebarView: AvailableSidebarViews | null;
|
||||
setSidebarView: (view: AvailableSidebarViews | null) => void;
|
||||
|
||||
disableVideoFocusTrap: boolean;
|
||||
setDisableVideoFocusTrap: (enabled: boolean) => void;
|
||||
|
||||
isWakeOnLanModalVisible: boolean;
|
||||
setWakeOnLanModalVisibility: (enabled: boolean) => void;
|
||||
|
||||
toggleSidebarView: (view: AvailableSidebarViews) => void;
|
||||
|
||||
modalView: AvailableModalViews | null;
|
||||
setModalView: (view: AvailableModalViews | null) => void;
|
||||
|
||||
isAttachedVirtualKeyboardVisible: boolean;
|
||||
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
|
||||
|
||||
enableTerminal: boolean;
|
||||
setEnableTerminal: (enabled: UIState["enableTerminal"]) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UIState>(set => ({
|
||||
enableTerminal: false,
|
||||
setEnableTerminal: enabled => set({ enableTerminal: enabled }),
|
||||
|
||||
sidebarView: null,
|
||||
setSidebarView: view => set({ sidebarView: view }),
|
||||
|
||||
disableVideoFocusTrap: false,
|
||||
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
|
||||
|
||||
isWakeOnLanModalVisible: false,
|
||||
setWakeOnLanModalVisibility: enabled => set({ isWakeOnLanModalVisible: enabled }),
|
||||
|
||||
toggleSidebarView: view =>
|
||||
set(state => {
|
||||
if (state.sidebarView === view) {
|
||||
return { sidebarView: null };
|
||||
} else {
|
||||
return { sidebarView: view };
|
||||
}
|
||||
}),
|
||||
|
||||
modalView: null,
|
||||
setModalView: view => set({ modalView: view }),
|
||||
|
||||
isAttachedVirtualKeyboardVisible: true,
|
||||
setAttachedVirtualKeyboardVisibility: enabled =>
|
||||
set({ isAttachedVirtualKeyboardVisible: enabled }),
|
||||
}));
|
||||
|
||||
interface RTCState {
|
||||
peerConnection: RTCPeerConnection | null;
|
||||
setPeerConnection: (pc: RTCState["peerConnection"]) => void;
|
||||
|
||||
setRpcDataChannel: (channel: RTCDataChannel) => void;
|
||||
rpcDataChannel: RTCDataChannel | null;
|
||||
|
||||
diskChannel: RTCDataChannel | null;
|
||||
setDiskChannel: (channel: RTCDataChannel) => void;
|
||||
|
||||
peerConnectionState: RTCPeerConnectionState | null;
|
||||
setPeerConnectionState: (state: RTCPeerConnectionState) => void;
|
||||
|
||||
transceiver: RTCRtpTransceiver | null;
|
||||
setTransceiver: (transceiver: RTCRtpTransceiver) => void;
|
||||
|
||||
mediaStream: MediaStream | null;
|
||||
setMediaStream: (stream: MediaStream) => void;
|
||||
|
||||
videoStreamStats: RTCInboundRtpStreamStats | null;
|
||||
appendVideoStreamStats: (state: RTCInboundRtpStreamStats) => void;
|
||||
videoStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>;
|
||||
|
||||
isTurnServerInUse: boolean;
|
||||
setTurnServerInUse: (inUse: boolean) => void;
|
||||
|
||||
inboundRtpStats: Map<number, RTCInboundRtpStreamStats>;
|
||||
appendInboundRtpStats: (state: RTCInboundRtpStreamStats) => void;
|
||||
clearInboundRtpStats: () => void;
|
||||
|
||||
candidatePairStats: Map<number, RTCIceCandidatePairStats>;
|
||||
appendCandidatePairStats: (pair: RTCIceCandidatePairStats) => void;
|
||||
clearCandidatePairStats: () => void;
|
||||
|
||||
// Remote ICE candidates stat type doesn't exist as of today
|
||||
localCandidateStats: Map<number, RTCIceCandidateStats>;
|
||||
appendLocalCandidateStats: (stats: RTCIceCandidateStats) => void;
|
||||
|
||||
remoteCandidateStats: Map<number, RTCIceCandidateStats>;
|
||||
appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => void;
|
||||
|
||||
// Disk data channel stats type doesn't exist as of today
|
||||
diskDataChannelStats: Map<number, RTCDataChannelStats>;
|
||||
appendDiskDataChannelStats: (stat: RTCDataChannelStats) => void;
|
||||
|
||||
terminalChannel: RTCDataChannel | null;
|
||||
setTerminalChannel: (channel: RTCDataChannel) => void;
|
||||
}
|
||||
|
||||
export const useRTCStore = create<RTCState>(set => ({
|
||||
peerConnection: null,
|
||||
setPeerConnection: pc => set({ peerConnection: pc }),
|
||||
|
||||
rpcDataChannel: null,
|
||||
setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
|
||||
|
||||
transceiver: null,
|
||||
setTransceiver: transceiver => set({ transceiver }),
|
||||
|
||||
peerConnectionState: null,
|
||||
setPeerConnectionState: state => set({ peerConnectionState: state }),
|
||||
|
||||
diskChannel: null,
|
||||
setDiskChannel: channel => set({ diskChannel: channel }),
|
||||
|
||||
mediaStream: null,
|
||||
setMediaStream: stream => set({ mediaStream: stream }),
|
||||
|
||||
videoStreamStats: null,
|
||||
appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
|
||||
videoStreamStatsHistory: new Map(),
|
||||
|
||||
isTurnServerInUse: false,
|
||||
setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
|
||||
|
||||
inboundRtpStats: new Map(),
|
||||
appendInboundRtpStats: newStat => {
|
||||
set(prevState => ({
|
||||
inboundRtpStats: appendStatToMap(newStat, prevState.inboundRtpStats),
|
||||
}));
|
||||
},
|
||||
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
|
||||
|
||||
candidatePairStats: new Map(),
|
||||
appendCandidatePairStats: newStat => {
|
||||
set(prevState => ({
|
||||
candidatePairStats: appendStatToMap(newStat, prevState.candidatePairStats),
|
||||
}));
|
||||
},
|
||||
clearCandidatePairStats: () => set({ candidatePairStats: new Map() }),
|
||||
|
||||
localCandidateStats: new Map(),
|
||||
appendLocalCandidateStats: newStat => {
|
||||
set(prevState => ({
|
||||
localCandidateStats: appendStatToMap(newStat, prevState.localCandidateStats),
|
||||
}));
|
||||
},
|
||||
|
||||
remoteCandidateStats: new Map(),
|
||||
appendRemoteCandidateStats: newStat => {
|
||||
set(prevState => ({
|
||||
remoteCandidateStats: appendStatToMap(newStat, prevState.remoteCandidateStats),
|
||||
}));
|
||||
},
|
||||
|
||||
diskDataChannelStats: new Map(),
|
||||
appendDiskDataChannelStats: newStat => {
|
||||
set(prevState => ({
|
||||
diskDataChannelStats: appendStatToMap(newStat, prevState.diskDataChannelStats),
|
||||
}));
|
||||
},
|
||||
|
||||
// Add these new properties to the store implementation
|
||||
terminalChannel: null,
|
||||
setTerminalChannel: channel => set({ terminalChannel: channel }),
|
||||
}));
|
||||
|
||||
interface MouseState {
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
setMousePosition: (x: number, y: number) => void;
|
||||
}
|
||||
|
||||
export const useMouseStore = create<MouseState>(set => ({
|
||||
mouseX: 0,
|
||||
mouseY: 0,
|
||||
setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }),
|
||||
}));
|
||||
|
||||
export interface VideoState {
|
||||
width: number;
|
||||
height: number;
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
setClientSize: (width: number, height: number) => void;
|
||||
setSize: (width: number, height: number) => void;
|
||||
hdmiState: "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting";
|
||||
setHdmiState: (state: {
|
||||
ready: boolean;
|
||||
error?: Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const useVideoStore = create<VideoState>(set => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
|
||||
clientWidth: 0,
|
||||
clientHeight: 0,
|
||||
|
||||
// The video element's client size
|
||||
setClientSize: (clientWidth, clientHeight) => set({ clientWidth, clientHeight }),
|
||||
|
||||
// Resolution
|
||||
setSize: (width, height) => set({ width, height }),
|
||||
|
||||
hdmiState: "connecting",
|
||||
setHdmiState: state => {
|
||||
if (!state) return;
|
||||
const { ready, error } = state;
|
||||
|
||||
if (ready) {
|
||||
return set({ hdmiState: "ready" });
|
||||
} else if (error) {
|
||||
return set({ hdmiState: error });
|
||||
} else {
|
||||
return set({ hdmiState: "connecting" });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
interface SettingsState {
|
||||
isCursorHidden: boolean;
|
||||
setCursorVisibility: (enabled: boolean) => void;
|
||||
|
||||
mouseMode: string;
|
||||
setMouseMode: (mode: string) => void;
|
||||
|
||||
debugMode: boolean;
|
||||
setDebugMode: (enabled: boolean) => void;
|
||||
|
||||
// Add new developer mode state
|
||||
developerMode: boolean;
|
||||
setDeveloperMode: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create(
|
||||
persist<SettingsState>(
|
||||
set => ({
|
||||
isCursorHidden: false,
|
||||
setCursorVisibility: enabled => set({ isCursorHidden: enabled }),
|
||||
|
||||
mouseMode: "absolute",
|
||||
setMouseMode: mode => set({ mouseMode: mode }),
|
||||
|
||||
debugMode: import.meta.env.DEV,
|
||||
setDebugMode: enabled => set({ debugMode: enabled }),
|
||||
|
||||
// Add developer mode with default value
|
||||
developerMode: false,
|
||||
setDeveloperMode: enabled => set({ developerMode: enabled }),
|
||||
}),
|
||||
{
|
||||
name: "settings",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export interface RemoteVirtualMediaState {
|
||||
source: "WebRTC" | "HTTP" | "Storage" | null;
|
||||
mode: "CDROM" | "Disk" | null;
|
||||
filename: string | null;
|
||||
url: string | null;
|
||||
path: string | null;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
export interface MountMediaState {
|
||||
localFile: File | null;
|
||||
setLocalFile: (file: MountMediaState["localFile"]) => void;
|
||||
|
||||
remoteVirtualMediaState: RemoteVirtualMediaState | null;
|
||||
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
|
||||
|
||||
modalView: "mode" | "browser" | "url" | "device" | "upload" | "error" | null;
|
||||
setModalView: (view: MountMediaState["modalView"]) => void;
|
||||
|
||||
isMountMediaDialogOpen: boolean;
|
||||
setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => void;
|
||||
|
||||
uploadedFiles: { name: string; size: string; uploadedAt: string }[];
|
||||
addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) => void;
|
||||
|
||||
errorMessage: string | null;
|
||||
setErrorMessage: (message: string | null) => void;
|
||||
}
|
||||
|
||||
export const useMountMediaStore = create<MountMediaState>(set => ({
|
||||
localFile: null,
|
||||
setLocalFile: file => set({ localFile: file }),
|
||||
|
||||
remoteVirtualMediaState: null,
|
||||
setRemoteVirtualMediaState: state => set({ remoteVirtualMediaState: state }),
|
||||
|
||||
modalView: "mode",
|
||||
setModalView: view => set({ modalView: view }),
|
||||
|
||||
isMountMediaDialogOpen: false,
|
||||
setIsMountMediaDialogOpen: isOpen => set({ isMountMediaDialogOpen: isOpen }),
|
||||
|
||||
uploadedFiles: [],
|
||||
addUploadedFile: file =>
|
||||
set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })),
|
||||
|
||||
errorMessage: null,
|
||||
setErrorMessage: message => set({ errorMessage: message }),
|
||||
}));
|
||||
|
||||
export interface HidState {
|
||||
activeKeys: number[];
|
||||
activeModifiers: number[];
|
||||
|
||||
updateActiveKeysAndModifiers: (keysAndModifiers: {
|
||||
keys: number[];
|
||||
modifiers: number[];
|
||||
}) => void;
|
||||
|
||||
altGrArmed: boolean;
|
||||
setAltGrArmed: (armed: boolean) => void;
|
||||
|
||||
altGrTimer: number | null; // _altGrCtrlTime
|
||||
setAltGrTimer: (timeout: number | null) => void;
|
||||
|
||||
altGrCtrlTime: number; // _altGrCtrlTime
|
||||
setAltGrCtrlTime: (time: number) => void;
|
||||
|
||||
isNumLockActive: boolean;
|
||||
setIsNumLockActive: (enabled: boolean) => void;
|
||||
|
||||
isScrollLockActive: boolean;
|
||||
setIsScrollLockActive: (enabled: boolean) => void;
|
||||
|
||||
isVirtualKeyboardEnabled: boolean;
|
||||
setVirtualKeyboardEnabled: (enabled: boolean) => void;
|
||||
|
||||
isCapsLockActive: boolean;
|
||||
setIsCapsLockActive: (enabled: boolean) => void;
|
||||
|
||||
isPasteModeEnabled: boolean;
|
||||
setPasteModeEnabled: (enabled: boolean) => void;
|
||||
|
||||
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed";
|
||||
setUsbState: (state: HidState["usbState"]) => void;
|
||||
}
|
||||
|
||||
export const useHidStore = create<HidState>(set => ({
|
||||
activeKeys: [],
|
||||
activeModifiers: [],
|
||||
updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
|
||||
return set({ activeKeys: keys, activeModifiers: modifiers });
|
||||
},
|
||||
|
||||
altGrArmed: false,
|
||||
setAltGrArmed: armed => set({ altGrArmed: armed }),
|
||||
|
||||
altGrTimer: 0,
|
||||
setAltGrTimer: timeout => set({ altGrTimer: timeout }),
|
||||
|
||||
altGrCtrlTime: 0,
|
||||
setAltGrCtrlTime: time => set({ altGrCtrlTime: time }),
|
||||
|
||||
isNumLockActive: false,
|
||||
setIsNumLockActive: enabled => set({ isNumLockActive: enabled }),
|
||||
|
||||
isScrollLockActive: false,
|
||||
setIsScrollLockActive: enabled => set({ isScrollLockActive: enabled }),
|
||||
|
||||
isVirtualKeyboardEnabled: false,
|
||||
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
|
||||
|
||||
isCapsLockActive: false,
|
||||
setIsCapsLockActive: enabled => set({ isCapsLockActive: enabled }),
|
||||
|
||||
isPasteModeEnabled: false,
|
||||
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
|
||||
|
||||
// Add these new properties for USB state
|
||||
usbState: "not attached",
|
||||
setUsbState: state => set({ usbState: state }),
|
||||
}));
|
||||
|
||||
export const useUserStore = create<UserState>(set => ({
|
||||
user: null,
|
||||
setUser: user => set({ user }),
|
||||
}));
|
||||
|
||||
export interface UpdateState {
|
||||
isUpdatePending: boolean;
|
||||
setIsUpdatePending: (isPending: boolean) => void;
|
||||
updateDialogHasBeenMinimized: boolean;
|
||||
otaState: {
|
||||
updating: boolean;
|
||||
error: string | null;
|
||||
|
||||
metadataFetchedAt: string | null;
|
||||
|
||||
// App update
|
||||
appUpdatePending: boolean;
|
||||
|
||||
appDownloadProgress: number;
|
||||
appDownloadFinishedAt: string | null;
|
||||
|
||||
appVerificationProgress: number;
|
||||
appVerifiedAt: string | null;
|
||||
|
||||
appUpdateProgress: number;
|
||||
appUpdatedAt: string | null;
|
||||
|
||||
// System update
|
||||
systemUpdatePending: boolean;
|
||||
|
||||
systemDownloadProgress: number;
|
||||
systemDownloadFinishedAt: string | null;
|
||||
|
||||
systemVerificationProgress: number;
|
||||
systemVerifiedAt: string | null;
|
||||
|
||||
systemUpdateProgress: number;
|
||||
systemUpdatedAt: string | null;
|
||||
};
|
||||
setOtaState: (state: UpdateState["otaState"]) => void;
|
||||
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
||||
modalView:
|
||||
| "loading"
|
||||
| "updating"
|
||||
| "upToDate"
|
||||
| "updateAvailable"
|
||||
| "updateCompleted"
|
||||
| "error";
|
||||
setModalView: (view: UpdateState["modalView"]) => void;
|
||||
isUpdateDialogOpen: boolean;
|
||||
setIsUpdateDialogOpen: (isOpen: boolean) => void;
|
||||
setUpdateErrorMessage: (errorMessage: string) => void;
|
||||
updateErrorMessage: string | null;
|
||||
}
|
||||
|
||||
export const useUpdateStore = create<UpdateState>(set => ({
|
||||
isUpdatePending: false,
|
||||
setIsUpdatePending: isPending => set({ isUpdatePending: isPending }),
|
||||
|
||||
setOtaState: state => set({ otaState: state }),
|
||||
otaState: {
|
||||
updating: false,
|
||||
error: null,
|
||||
metadataFetchedAt: null,
|
||||
appUpdatePending: false,
|
||||
systemUpdatePending: false,
|
||||
appDownloadProgress: 0,
|
||||
appDownloadFinishedAt: null,
|
||||
appVerificationProgress: 0,
|
||||
appVerifiedAt: null,
|
||||
systemDownloadProgress: 0,
|
||||
systemDownloadFinishedAt: null,
|
||||
systemVerificationProgress: 0,
|
||||
systemVerifiedAt: null,
|
||||
appUpdateProgress: 0,
|
||||
appUpdatedAt: null,
|
||||
systemUpdateProgress: 0,
|
||||
systemUpdatedAt: null,
|
||||
},
|
||||
|
||||
updateDialogHasBeenMinimized: false,
|
||||
setUpdateDialogHasBeenMinimized: hasBeenMinimized =>
|
||||
set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
|
||||
modalView: "loading",
|
||||
setModalView: view => set({ modalView: view }),
|
||||
isUpdateDialogOpen: false,
|
||||
setIsUpdateDialogOpen: isOpen => set({ isUpdateDialogOpen: isOpen }),
|
||||
updateErrorMessage: null,
|
||||
setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }),
|
||||
}));
|
||||
|
||||
interface LocalAuthModalState {
|
||||
modalView:
|
||||
| "createPassword"
|
||||
| "deletePassword"
|
||||
| "updatePassword"
|
||||
| "creationSuccess"
|
||||
| "deleteSuccess"
|
||||
| "updateSuccess";
|
||||
errorMessage: string | null;
|
||||
setModalView: (view: LocalAuthModalState["modalView"]) => void;
|
||||
setErrorMessage: (message: string | null) => void;
|
||||
}
|
||||
|
||||
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
|
||||
modalView: "createPassword",
|
||||
errorMessage: null,
|
||||
setModalView: view => set({ modalView: view }),
|
||||
setErrorMessage: message => set({ errorMessage: message }),
|
||||
}));
|
||||
21
ui/src/hooks/useInterval.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export default function useInterval(callback: () => void, delay: number) {
|
||||
const savedCallback = useRef<typeof callback>();
|
||||
|
||||
// Save the callback directly in the useRef object
|
||||
savedCallback.current = callback;
|
||||
|
||||
// Set up the interval.
|
||||
useEffect(() => {
|
||||
function tick() {
|
||||
if (!savedCallback.current) return;
|
||||
savedCallback.current();
|
||||
}
|
||||
|
||||
if (delay !== null) {
|
||||
const id = setInterval(tick, delay);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
}, [delay]);
|
||||
}
|
||||
26
ui/src/hooks/useIsMounted.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook that determines if the component is currently mounted.
|
||||
* @returns {() => boolean} A function that returns a boolean value indicating whether the component is mounted.
|
||||
* @public
|
||||
* @see [Documentation](https://usehooks-ts.com/react-hook/use-is-mounted)
|
||||
* @example
|
||||
* ```tsx
|
||||
* const isComponentMounted = useIsMounted();
|
||||
* // Use isComponentMounted() to check if the component is currently mounted before performing certain actions.
|
||||
* ```
|
||||
*/
|
||||
export function useIsMounted(): () => boolean {
|
||||
const isMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(() => isMounted.current, []);
|
||||
}
|
||||
73
ui/src/hooks/useJsonRpc.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useRTCStore } from "@/hooks/stores";
|
||||
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: string;
|
||||
method: string;
|
||||
params: object;
|
||||
id: number | string;
|
||||
}
|
||||
|
||||
type JsonRpcResponse =
|
||||
| {
|
||||
jsonrpc: string;
|
||||
result: boolean | number | object | string | [];
|
||||
id: string | number;
|
||||
}
|
||||
| {
|
||||
jsonrpc: string;
|
||||
error: { code: number; data?: string; message: string };
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>();
|
||||
let requestCounter = 0;
|
||||
|
||||
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
|
||||
const send = useCallback(
|
||||
(method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
requestCounter++;
|
||||
const payload = { jsonrpc: "2.0", method, params, id: requestCounter };
|
||||
// Store the callback if it exists
|
||||
if (callback) callbackStore.set(payload.id, callback);
|
||||
|
||||
rpcDataChannel.send(JSON.stringify(payload));
|
||||
},
|
||||
[rpcDataChannel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rpcDataChannel) return;
|
||||
|
||||
const messageHandler = (e: MessageEvent) => {
|
||||
const payload = JSON.parse(e.data) as JsonRpcResponse | JsonRpcRequest;
|
||||
|
||||
// The "API" can also "request" data from the client
|
||||
// If the payload has a method, it's a request
|
||||
if ("method" in payload) {
|
||||
onRequest && onRequest(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("error" in payload) console.error(payload.error);
|
||||
if (!payload.id) return;
|
||||
|
||||
const callback = callbackStore.get(payload.id);
|
||||
if (callback) {
|
||||
callback(payload);
|
||||
callbackStore.delete(payload.id);
|
||||
}
|
||||
};
|
||||
|
||||
rpcDataChannel.addEventListener("message", messageHandler);
|
||||
|
||||
return () => {
|
||||
rpcDataChannel.removeEventListener("message", messageHandler);
|
||||
};
|
||||
}, [rpcDataChannel, onRequest]);
|
||||
|
||||
return [send];
|
||||
}
|
||||
31
ui/src/hooks/useKeyboard.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useCallback } from "react";
|
||||
import { useHidStore, useRTCStore } from "@/hooks/stores";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
|
||||
export default function useKeyboard() {
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
const updateActiveKeysAndModifiers = useHidStore(
|
||||
state => state.updateActiveKeysAndModifiers,
|
||||
);
|
||||
|
||||
const sendKeyboardEvent = useCallback(
|
||||
(keys: number[], modifiers: number[]) => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
|
||||
|
||||
send("keyboardReport", { keys, modifier: accModifier });
|
||||
|
||||
// We do this for the info bar to display the currently pressed keys for the user
|
||||
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers });
|
||||
},
|
||||
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers],
|
||||
);
|
||||
|
||||
const resetKeyboardState = useCallback(() => {
|
||||
sendKeyboardEvent([], []);
|
||||
}, [sendKeyboardEvent]);
|
||||
|
||||
return { sendKeyboardEvent, resetKeyboardState };
|
||||
}
|
||||
132
ui/src/hooks/useResizeObserver.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import { useIsMounted } from "./useIsMounted";
|
||||
|
||||
/** The size of the observed element. */
|
||||
type Size = {
|
||||
/** The width of the observed element. */
|
||||
width: number | undefined;
|
||||
/** The height of the observed element. */
|
||||
height: number | undefined;
|
||||
};
|
||||
|
||||
/** The options for the ResizeObserver. */
|
||||
type UseResizeObserverOptions<T extends HTMLElement = HTMLElement> = {
|
||||
/** The ref of the element to observe. */
|
||||
ref: RefObject<T>;
|
||||
/**
|
||||
* When using `onResize`, the hook doesn't re-render on element size changes; it delegates handling to the provided callback.
|
||||
* @default undefined
|
||||
*/
|
||||
onResize?: (size: Size) => void;
|
||||
/**
|
||||
* The box model to use for the ResizeObserver.
|
||||
* @default 'content-box'
|
||||
*/
|
||||
box?: "border-box" | "content-box" | "device-pixel-content-box";
|
||||
};
|
||||
|
||||
const initialSize: Size = {
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook that observes the size of an element using the ResizeObserver API.
|
||||
* @template T - The type of the element to observe.
|
||||
* @param {UseResizeObserverOptions<T>} options - The options for the ResizeObserver.
|
||||
* @returns {Size} - The size of the observed element.
|
||||
* @public
|
||||
* @see [Documentation](https://usehooks-ts.com/react-hook/use-resize-observer)
|
||||
* @see [MDN ResizeObserver API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
|
||||
* @example
|
||||
* ```tsx
|
||||
* const myRef = useRef(null);
|
||||
* const { width = 0, height = 0 } = useResizeObserver({
|
||||
* ref: myRef,
|
||||
* box: 'content-box',
|
||||
* });
|
||||
*
|
||||
* <div ref={myRef}>Hello, world!</div>
|
||||
* ```
|
||||
*/
|
||||
export function useResizeObserver<T extends HTMLElement = HTMLElement>(
|
||||
options: UseResizeObserverOptions<T>,
|
||||
): Size {
|
||||
const { ref, box = "content-box" } = options;
|
||||
const [{ width, height }, setSize] = useState<Size>(initialSize);
|
||||
const isMounted = useIsMounted();
|
||||
const previousSize = useRef<Size>({ ...initialSize });
|
||||
const onResize = useRef<((size: Size) => void) | undefined>(undefined);
|
||||
onResize.current = options.onResize;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
if (typeof window === "undefined" || !("ResizeObserver" in window)) return;
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const boxProp =
|
||||
box === "border-box"
|
||||
? "borderBoxSize"
|
||||
: box === "device-pixel-content-box"
|
||||
? "devicePixelContentBoxSize"
|
||||
: "contentBoxSize";
|
||||
|
||||
const newWidth = extractSize(entry, boxProp, "inlineSize");
|
||||
const newHeight = extractSize(entry, boxProp, "blockSize");
|
||||
|
||||
const hasChanged =
|
||||
previousSize.current.width !== newWidth ||
|
||||
previousSize.current.height !== newHeight;
|
||||
|
||||
if (hasChanged) {
|
||||
const newSize: Size = { width: newWidth, height: newHeight };
|
||||
previousSize.current.width = newWidth;
|
||||
previousSize.current.height = newHeight;
|
||||
|
||||
if (onResize.current) {
|
||||
onResize.current(newSize);
|
||||
} else {
|
||||
if (isMounted()) {
|
||||
setSize(newSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(ref.current, { box });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [box, ref.current, isMounted]);
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/** @private */
|
||||
type BoxSizesKey = keyof Pick<
|
||||
ResizeObserverEntry,
|
||||
"borderBoxSize" | "contentBoxSize" | "devicePixelContentBoxSize"
|
||||
>;
|
||||
|
||||
function extractSize(
|
||||
entry: ResizeObserverEntry,
|
||||
box: BoxSizesKey,
|
||||
sizeType: keyof ResizeObserverSize,
|
||||
): number | undefined {
|
||||
if (!entry[box]) {
|
||||
if (box === "contentBoxSize") {
|
||||
return entry.contentRect[sizeType === "inlineSize" ? "width" : "height"];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Array.isArray(entry[box])
|
||||
? entry[box][0][sizeType]
|
||||
: // @ts-ignore Support Firefox's non-standard behavior
|
||||
(entry[box][sizeType] as number);
|
||||
}
|
||||
193
ui/src/index.css
Normal file
@@ -0,0 +1,193 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@property --grid-color-start {
|
||||
syntax: "<color>";
|
||||
initial-value: theme("colors.blue.50/10");
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@property --grid-color-end {
|
||||
syntax: "<color>";
|
||||
initial-value: theme("colors.blue.50/100");
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
.grid-card {
|
||||
background-image: linear-gradient(
|
||||
to top right,
|
||||
var(--grid-color-start),
|
||||
var(--grid-color-end)
|
||||
);
|
||||
transition:
|
||||
--grid-color-start 300ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
--grid-color-end 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.group:hover .grid-card {
|
||||
--grid-color-start: theme("colors.blue.100/50");
|
||||
--grid-color-end: theme("colors.blue.50/50");
|
||||
}
|
||||
|
||||
video::-webkit-media-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hg-theme-default {
|
||||
@apply !font-display font-normal;
|
||||
}
|
||||
|
||||
.hg-theme-default .hg-button {
|
||||
@apply border !border-b border-slate-800/25 !border-b-slate-800/25 !shadow-sm;
|
||||
}
|
||||
|
||||
.hg-theme-default .hg-button span {
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.keyboardContainer {
|
||||
display: flex;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
justify-content: center;
|
||||
width: 1024px;
|
||||
margin: 0 auto;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.simple-keyboard.hg-theme-default {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.simple-keyboard-main.simple-keyboard {
|
||||
@apply w-full md:w-[80%];
|
||||
background: none;
|
||||
}
|
||||
|
||||
.simple-keyboard-main.simple-keyboard .hg-row:first-child {
|
||||
@apply mb-[10px];
|
||||
}
|
||||
|
||||
.simple-keyboard-arrows.simple-keyboard {
|
||||
@apply self-end;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.simple-keyboard .hg-button.selectedButton {
|
||||
background: rgba(5, 25, 70, 0.53);
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.simple-keyboard .hg-button.emptySpace {
|
||||
@apply pointer-events-none;
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.simple-keyboard-arrows .hg-row {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.simple-keyboard-arrows .hg-button {
|
||||
@apply flex w-[50px] grow-0 items-center justify-center;
|
||||
}
|
||||
|
||||
.controlArrows {
|
||||
@apply flex items-center justify-between w-full md:w-1/5;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.simple-keyboard-control.simple-keyboard {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.simple-keyboard-control.simple-keyboard .hg-row:first-child {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.controlArrows .simple-keyboard-control.simple-keyboard .hg-row:first-child {
|
||||
@apply mb-[4px] md:mb-[10px];
|
||||
}
|
||||
|
||||
.hg-button {
|
||||
@apply dark:!bg-slate-800 dark:text-white;
|
||||
}
|
||||
|
||||
.simple-keyboard-control .hg-button {
|
||||
@apply flex w-[50px] grow-0 items-center justify-center;
|
||||
}
|
||||
|
||||
.numPad {
|
||||
@apply flex items-end;
|
||||
}
|
||||
|
||||
.simple-keyboard-numpad.simple-keyboard {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.simple-keyboard-numpad.simple-keyboard {
|
||||
@apply w-[160px];
|
||||
}
|
||||
|
||||
.simple-keyboard-numpad.simple-keyboard .hg-button {
|
||||
@apply flex w-[50px] items-center justify-center;
|
||||
}
|
||||
|
||||
.simple-keyboard-numpadEnd.simple-keyboard {
|
||||
@apply w-[50px];
|
||||
background: none;
|
||||
margin: 0;
|
||||
padding: 5px 5px 5px 0;
|
||||
}
|
||||
|
||||
.simple-keyboard-numpadEnd.simple-keyboard .hg-button {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.simple-keyboard-numpadEnd .hg-button.hg-standardBtn.hg-button-plus {
|
||||
@apply h-[85px];
|
||||
}
|
||||
|
||||
.simple-keyboard-numpadEnd.simple-keyboard .hg-button.hg-button-enter {
|
||||
@apply h-[85px];
|
||||
}
|
||||
|
||||
.simple-keyboard.hg-theme-default .hg-button.hg-selectedButton {
|
||||
background: rgba(5, 25, 70, 0.53);
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.hg-button.hg-standardBtn[data-skbtn="Space"] {
|
||||
@apply md:!w-[350px];
|
||||
}
|
||||
|
||||
.hg-theme-default .hg-row .combination-key {
|
||||
@apply inline-flex !h-auto !w-auto flex-grow-0 py-1 text-xs;
|
||||
}
|
||||
|
||||
.hg-theme-default .hg-row:has(.combination-key) {
|
||||
/*margin-bottom: 100px !important;*/
|
||||
}
|
||||
|
||||
.hg-theme-default .hg-row .hg-button-container,
|
||||
.hg-theme-default .hg-row .hg-button:not(:last-child) {
|
||||
@apply !mr-[2px] md:!mr-[5px];
|
||||
}
|
||||
|
||||
/* Hide the scrollbar by setting the scrollbar color to the background color */
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-color: theme("colors.gray.900") #002b36;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
214
ui/src/keyboardMappings.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
export const keys = {
|
||||
AltLeft: 0xe2,
|
||||
AltRight: 0xe6,
|
||||
ArrowDown: 0x51,
|
||||
ArrowLeft: 0x50,
|
||||
ArrowRight: 0x4f,
|
||||
ArrowUp: 0x52,
|
||||
Backquote: 0x35,
|
||||
Backslash: 0x31,
|
||||
Backspace: 0x2a,
|
||||
BracketLeft: 0x2f,
|
||||
BracketRight: 0x30,
|
||||
CapsLock: 0x39,
|
||||
Comma: 0x36,
|
||||
ContextMenu: 0,
|
||||
Delete: 0x4c,
|
||||
Digit0: 0x27,
|
||||
Digit1: 0x1e,
|
||||
Digit2: 0x1f,
|
||||
Digit3: 0x20,
|
||||
Digit4: 0x21,
|
||||
Digit5: 0x22,
|
||||
Digit6: 0x23,
|
||||
Digit7: 0x24,
|
||||
Digit8: 0x25,
|
||||
Digit9: 0x26,
|
||||
End: 0x4d,
|
||||
Enter: 0x28,
|
||||
Equal: 0x2e,
|
||||
Escape: 0x29,
|
||||
F1: 0x3a,
|
||||
F2: 0x3b,
|
||||
F3: 0x3c,
|
||||
F4: 0x3d,
|
||||
F5: 0x3e,
|
||||
F6: 0x3f,
|
||||
F7: 0x40,
|
||||
F8: 0x41,
|
||||
F9: 0x42,
|
||||
F10: 0x43,
|
||||
F11: 0x44,
|
||||
F12: 0x45,
|
||||
F13: 0x68,
|
||||
Home: 0x4a,
|
||||
Insert: 0x49,
|
||||
IntlBackslash: 0x31,
|
||||
KeyA: 0x04,
|
||||
KeyB: 0x05,
|
||||
KeyC: 0x06,
|
||||
KeyD: 0x07,
|
||||
KeyE: 0x08,
|
||||
KeyF: 0x09,
|
||||
KeyG: 0x0a,
|
||||
KeyH: 0x0b,
|
||||
KeyI: 0x0c,
|
||||
KeyJ: 0x0d,
|
||||
KeyK: 0x0e,
|
||||
KeyL: 0x0f,
|
||||
KeyM: 0x10,
|
||||
KeyN: 0x11,
|
||||
KeyO: 0x12,
|
||||
KeyP: 0x13,
|
||||
KeyQ: 0x14,
|
||||
KeyR: 0x15,
|
||||
KeyS: 0x16,
|
||||
KeyT: 0x17,
|
||||
KeyU: 0x18,
|
||||
KeyV: 0x19,
|
||||
KeyW: 0x1a,
|
||||
KeyX: 0x1b,
|
||||
KeyY: 0x1c,
|
||||
KeyZ: 0x1d,
|
||||
KeypadExclamation: 0xcf,
|
||||
Minus: 0x2d,
|
||||
NumLock: 0x53,
|
||||
Numpad0: 0x62,
|
||||
Numpad1: 0x59,
|
||||
Numpad2: 0x5a,
|
||||
Numpad3: 0x5b,
|
||||
Numpad4: 0x5c,
|
||||
Numpad5: 0x5d,
|
||||
Numpad6: 0x5e,
|
||||
Numpad7: 0x5f,
|
||||
Numpad8: 0x60,
|
||||
Numpad9: 0x61,
|
||||
NumpadAdd: 0x57,
|
||||
NumpadDivide: 0x54,
|
||||
NumpadEnter: 0x58,
|
||||
NumpadMultiply: 0x55,
|
||||
NumpadSubtract: 0x56,
|
||||
NumpadDecimal: 0x63,
|
||||
PageDown: 0x4e,
|
||||
PageUp: 0x4b,
|
||||
Period: 0x37,
|
||||
Quote: 0x34,
|
||||
Semicolon: 0x33,
|
||||
Slash: 0x38,
|
||||
Space: 0x2c,
|
||||
Tab: 0x2b,
|
||||
} as Record<string, number>;
|
||||
|
||||
export const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
B: { key: "KeyB", shift: true },
|
||||
C: { key: "KeyC", shift: true },
|
||||
D: { key: "KeyD", shift: true },
|
||||
E: { key: "KeyE", shift: true },
|
||||
F: { key: "KeyF", shift: true },
|
||||
G: { key: "KeyG", shift: true },
|
||||
H: { key: "KeyH", shift: true },
|
||||
I: { key: "KeyI", shift: true },
|
||||
J: { key: "KeyJ", shift: true },
|
||||
K: { key: "KeyK", shift: true },
|
||||
L: { key: "KeyL", shift: true },
|
||||
M: { key: "KeyM", shift: true },
|
||||
N: { key: "KeyN", shift: true },
|
||||
O: { key: "KeyO", shift: true },
|
||||
P: { key: "KeyP", shift: true },
|
||||
Q: { key: "KeyQ", shift: true },
|
||||
R: { key: "KeyR", shift: true },
|
||||
S: { key: "KeyS", shift: true },
|
||||
T: { key: "KeyT", shift: true },
|
||||
U: { key: "KeyU", shift: true },
|
||||
V: { key: "KeyV", shift: true },
|
||||
W: { key: "KeyW", shift: true },
|
||||
X: { key: "KeyX", shift: true },
|
||||
Y: { key: "KeyY", shift: true },
|
||||
Z: { key: "KeyZ", shift: true },
|
||||
a: { key: "KeyA", shift: false },
|
||||
b: { key: "KeyB", shift: false },
|
||||
c: { key: "KeyC", shift: false },
|
||||
d: { key: "KeyD", shift: false },
|
||||
e: { key: "KeyE", shift: false },
|
||||
f: { key: "KeyF", shift: false },
|
||||
g: { key: "KeyG", shift: false },
|
||||
h: { key: "KeyH", shift: false },
|
||||
i: { key: "KeyI", shift: false },
|
||||
j: { key: "KeyJ", shift: false },
|
||||
k: { key: "KeyK", shift: false },
|
||||
l: { key: "KeyL", shift: false },
|
||||
m: { key: "KeyM", shift: false },
|
||||
n: { key: "KeyN", shift: false },
|
||||
o: { key: "KeyO", shift: false },
|
||||
p: { key: "KeyP", shift: false },
|
||||
q: { key: "KeyQ", shift: false },
|
||||
r: { key: "KeyR", shift: false },
|
||||
s: { key: "KeyS", shift: false },
|
||||
t: { key: "KeyT", shift: false },
|
||||
u: { key: "KeyU", shift: false },
|
||||
v: { key: "KeyV", shift: false },
|
||||
w: { key: "KeyW", shift: false },
|
||||
x: { key: "KeyX", shift: false },
|
||||
y: { key: "KeyY", shift: false },
|
||||
z: { key: "KeyZ", shift: false },
|
||||
1: { key: "Digit1", shift: false },
|
||||
"!": { key: "Digit1", shift: true },
|
||||
2: { key: "Digit2", shift: false },
|
||||
"@": { key: "Digit2", shift: true },
|
||||
3: { key: "Digit3", shift: false },
|
||||
"#": { key: "Digit3", shift: true },
|
||||
4: { key: "Digit4", shift: false },
|
||||
$: { key: "Digit4", shift: true },
|
||||
"%": { key: "Digit5", shift: true },
|
||||
5: { key: "Digit5", shift: false },
|
||||
"^": { key: "Digit6", shift: true },
|
||||
6: { key: "Digit6", shift: false },
|
||||
"&": { key: "Digit7", shift: true },
|
||||
7: { key: "Digit7", shift: false },
|
||||
"*": { key: "Digit8", shift: true },
|
||||
8: { key: "Digit8", shift: false },
|
||||
"(": { key: "Digit9", shift: true },
|
||||
9: { key: "Digit9", shift: false },
|
||||
")": { key: "Digit0", shift: true },
|
||||
0: { key: "Digit0", shift: false },
|
||||
"-": { key: "Minus", shift: false },
|
||||
_: { key: "Minus", shift: true },
|
||||
"=": { key: "Equal", shift: false },
|
||||
"+": { key: "Equal", shift: true },
|
||||
"'": { key: "Quote", shift: false },
|
||||
'"': { key: "Quote", shift: true },
|
||||
",": { key: "Comma", shift: false },
|
||||
"<": { key: "Comma", shift: true },
|
||||
"/": { key: "Slash", shift: false },
|
||||
"?": { key: "Slash", shift: true },
|
||||
".": { key: "Period", shift: false },
|
||||
">": { key: "Period", shift: true },
|
||||
";": { key: "Semicolon", shift: false },
|
||||
":": { key: "Semicolon", shift: true },
|
||||
"[": { key: "BracketLeft", shift: false },
|
||||
"{": { key: "BracketLeft", shift: true },
|
||||
"]": { key: "BracketRight", shift: false },
|
||||
"}": { key: "BracketRight", shift: true },
|
||||
"\\": { key: "Backslash", shift: false },
|
||||
"|": { key: "Backslash", shift: true },
|
||||
"`": { key: "Backquote", shift: false },
|
||||
"~": { key: "Backquote", shift: true },
|
||||
"§": { key: "IntlBackslash", shift: false },
|
||||
"±": { key: "IntlBackslash", shift: true },
|
||||
" ": { key: "Space", shift: false },
|
||||
"\n": { key: "Enter", shift: false },
|
||||
Enter: { key: "Enter", shift: false },
|
||||
Tab: { key: "Tab", shift: false },
|
||||
} as Record<string, { key: string | number; shift: boolean }>;
|
||||
|
||||
export const modifiers = {
|
||||
ControlLeft: 0x01,
|
||||
ControlRight: 0x10,
|
||||
ShiftLeft: 0x02,
|
||||
ShiftRight: 0x20,
|
||||
AltLeft: 0x04,
|
||||
AltRight: 0x40,
|
||||
MetaLeft: 0x08,
|
||||
MetaRight: 0x80,
|
||||
} as Record<string, number>;
|
||||
189
ui/src/main.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import Root from "./root";
|
||||
import "./index.css";
|
||||
import {
|
||||
createBrowserRouter,
|
||||
isRouteErrorResponse,
|
||||
redirect,
|
||||
RouterProvider,
|
||||
useRouteError,
|
||||
} from "react-router-dom";
|
||||
import DeviceRoute from "@routes/devices.$id";
|
||||
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
|
||||
import SetupRoute from "@routes/devices.$id.setup";
|
||||
import LoginRoute from "@routes/login";
|
||||
import SignupRoute from "@routes/signup";
|
||||
import AdoptRoute from "@routes/adopt";
|
||||
import DeviceIdRename from "@routes/devices.$id.rename";
|
||||
import DevicesIdDeregister from "@routes/devices.$id.deregister";
|
||||
import NotFoundPage from "@components/NotFoundPage";
|
||||
import EmptyCard from "@components/EmptyCard";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
|
||||
import Card from "@components/Card";
|
||||
import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
|
||||
import Notifications from "./notifications";
|
||||
import LoginLocalRoute from "./routes/login-local";
|
||||
import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
|
||||
import WelcomeRoute from "./routes/welcome-local";
|
||||
import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
|
||||
|
||||
export const isOnDevice = import.meta.env.MODE === "device";
|
||||
export const isInCloud = !isOnDevice;
|
||||
|
||||
export async function checkAuth() {
|
||||
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/me`, {
|
||||
mode: "cors",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
throw redirect(`/login?returnTo=${window.location.href}`);
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
let router;
|
||||
if (isOnDevice) {
|
||||
router = createBrowserRouter([
|
||||
{
|
||||
path: "/welcome/mode",
|
||||
element: <WelcomeLocalModeRoute />,
|
||||
action: WelcomeLocalModeRoute.action,
|
||||
},
|
||||
{
|
||||
path: "/welcome/password",
|
||||
element: <WelcomeLocalPasswordRoute />,
|
||||
action: WelcomeLocalPasswordRoute.action,
|
||||
},
|
||||
{
|
||||
path: "/welcome",
|
||||
element: <WelcomeRoute />,
|
||||
loader: WelcomeRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "/login-local",
|
||||
element: <LoginLocalRoute />,
|
||||
action: LoginLocalRoute.action,
|
||||
loader: LoginLocalRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
errorElement: <ErrorBoundary />,
|
||||
element: <DeviceRoute />,
|
||||
loader: DeviceRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "/adopt",
|
||||
element: <AdoptRoute />,
|
||||
loader: AdoptRoute.loader,
|
||||
errorElement: <ErrorBoundary />,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
router = createBrowserRouter([
|
||||
{
|
||||
errorElement: <ErrorBoundary />,
|
||||
children: [
|
||||
{ path: "signup", element: <SignupRoute /> },
|
||||
{ path: "login", element: <LoginRoute /> },
|
||||
{
|
||||
path: "/",
|
||||
element: <Root />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
loader: async () => {
|
||||
await checkAuth();
|
||||
return redirect(`/devices`);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
path: "devices/:id/setup",
|
||||
element: <SetupRoute />,
|
||||
action: SetupRoute.action,
|
||||
loader: SetupRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "devices/already-adopted",
|
||||
element: <DevicesAlreadyAdopted />,
|
||||
},
|
||||
{
|
||||
path: "devices/:id",
|
||||
element: <DeviceRoute />,
|
||||
loader: DeviceRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "devices/:id/deregister",
|
||||
element: <DevicesIdDeregister />,
|
||||
loader: DevicesIdDeregister.loader,
|
||||
action: DevicesIdDeregister.action,
|
||||
},
|
||||
{
|
||||
path: "devices/:id/rename",
|
||||
element: <DeviceIdRename />,
|
||||
loader: DeviceIdRename.loader,
|
||||
action: DeviceIdRename.action,
|
||||
},
|
||||
{ path: "devices", element: <DevicesRoute />, loader: DeviceListLoader },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<Notifications
|
||||
toastOptions={{
|
||||
className:
|
||||
"rounded border-none bg-white text-black shadow outline outline-1 outline-slate-800/30",
|
||||
}}
|
||||
max={2}
|
||||
/>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
const errorMessage = error?.data?.error?.message || error?.message;
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (error.status === 404) return <NotFoundPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<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 dark:text-white">
|
||||
<span className="text-sm">{errorMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
ui/src/notifications.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
|
||||
import React, { useEffect } from "react";
|
||||
import Card from "@/components/Card";
|
||||
|
||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
interface NotificationOptions {
|
||||
duration?: number;
|
||||
// Add other options as needed
|
||||
}
|
||||
|
||||
const ToastContent = ({
|
||||
icon,
|
||||
message,
|
||||
t,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
message: string;
|
||||
t: Toast;
|
||||
}) => (
|
||||
<Card
|
||||
className={`${
|
||||
t.visible ? "animate-enter" : "animate-leave"
|
||||
} pointer-events-auto z-30 w-full max-w-sm !shadow-xl`}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 p-2.5 px-2">
|
||||
{icon}
|
||||
<p className="text-[14px] font-medium text-gray-900 dark:text-gray-100">{message}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const notifications = {
|
||||
success: (message: string, options?: NotificationOptions) => {
|
||||
return toast.custom(
|
||||
t => (
|
||||
<ToastContent
|
||||
icon={<CheckCircleIcon className="w-5 h-5 text-green-500 dark:text-green-400" />}
|
||||
message={message}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
{ duration: 2000, ...options },
|
||||
);
|
||||
},
|
||||
|
||||
error: (message: string, options?: NotificationOptions) => {
|
||||
return toast.custom(
|
||||
t => (
|
||||
<ToastContent
|
||||
icon={<XCircleIcon className="w-5 h-5 text-red-500 dark:text-red-400" />}
|
||||
message={message}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
{ duration: 2000, ...options },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function useMaxToasts(max: number) {
|
||||
const { toasts } = useToasterStore();
|
||||
|
||||
useEffect(() => {
|
||||
toasts
|
||||
.filter(t => t.visible) // Only consider visible toasts
|
||||
.filter((_, i) => i >= max) // Is toast index over limit?
|
||||
.forEach(t => toast.dismiss(t.id)); // Dismiss – Use toast.remove(t.id) for no exit animation
|
||||
}, [toasts, max]);
|
||||
}
|
||||
|
||||
export function Notifications({
|
||||
max = 10,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Toaster> & {
|
||||
max?: number;
|
||||
}) {
|
||||
useMaxToasts(max);
|
||||
|
||||
return <Toaster {...props} />;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export default Object.assign(Notifications, {
|
||||
success: notifications.success,
|
||||
error: notifications.error,
|
||||
});
|
||||
7
ui/src/root.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
function Root() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export default Root;
|
||||
31
ui/src/routes/adopt.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { LoaderFunctionArgs, redirect } from "react-router-dom";
|
||||
import api from "../api";
|
||||
|
||||
const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams;
|
||||
|
||||
const tempToken = searchParams.get("tempToken");
|
||||
const deviceId = searchParams.get("deviceId");
|
||||
const oidcGoogle = searchParams.get("oidcGoogle");
|
||||
const clientId = searchParams.get("clientId");
|
||||
|
||||
const res = await api.POST(
|
||||
`${import.meta.env.VITE_SIGNAL_API}/cloud/register`,
|
||||
{
|
||||
token: tempToken,
|
||||
cloudApi: import.meta.env.VITE_CLOUD_API,
|
||||
oidcGoogle,
|
||||
clientId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error("Failed to register device");
|
||||
return redirect(import.meta.env.VITE_CLOUD_APP + `/devices/${deviceId}/setup`);
|
||||
};
|
||||
|
||||
export default function AdoptRoute() {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
AdoptRoute.loader = loader;
|
||||
141
ui/src/routes/devices.$id.deregister.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
Form,
|
||||
LoaderFunctionArgs,
|
||||
redirect,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
} from "react-router-dom";
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { CardHeader } from "@components/CardHeader";
|
||||
import DashboardNavbar from "@components/Header";
|
||||
import { User } from "@/hooks/stores";
|
||||
import { checkAuth } from "@/main";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
|
||||
|
||||
interface LoaderData {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
user: User;
|
||||
}
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { deviceId } = Object.fromEntries(await request.formData());
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${deviceId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
} catch (e) {
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
|
||||
return redirect("/devices");
|
||||
};
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const user = await checkAuth();
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
const { device } = (await res.json()) as {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
};
|
||||
|
||||
return { device, user };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { devices: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export default function DevicesIdDeregister() {
|
||||
const { device, user } = useLoaderData() as LoaderData;
|
||||
const error = useActionData() as { message: string };
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<DashboardNavbar
|
||||
isLoggedIn={!!user}
|
||||
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
|
||||
userEmail={user?.email}
|
||||
picture={user?.picture}
|
||||
/>
|
||||
|
||||
<div className="w-full h-full">
|
||||
<div className="mt-4">
|
||||
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
|
||||
<div className="space-y-4">
|
||||
<LinkButton
|
||||
size="SM"
|
||||
theme="blank"
|
||||
LeadingIcon={ChevronLeftIcon}
|
||||
text="Back to Devices"
|
||||
to="/devices"
|
||||
/>
|
||||
<Card className="max-w-3xl p-6">
|
||||
<div className="max-w-xl space-y-4">
|
||||
<CardHeader
|
||||
headline={`Deregister ${device.name || device.id} from your cloud account`}
|
||||
description={
|
||||
<>
|
||||
This will remove the device from your cloud account and revoke
|
||||
remote access to it.
|
||||
<br />
|
||||
Please note that local access will still be possible
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Fieldset>
|
||||
<Form method="POST" className="max-w-sm space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<input name="deviceId" type="hidden" value={device.id} />
|
||||
<LinkButton
|
||||
size="MD"
|
||||
theme="light"
|
||||
to="/devices"
|
||||
text="Cancel"
|
||||
textAlign="center"
|
||||
/>
|
||||
<Button
|
||||
size="MD"
|
||||
theme="danger"
|
||||
type="submit"
|
||||
text="Deregister from Cloud"
|
||||
textAlign="center"
|
||||
/>
|
||||
</div>
|
||||
{error?.message && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{error?.message}
|
||||
</p>
|
||||
)}
|
||||
</Form>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DevicesIdDeregister.loader = loader;
|
||||
DevicesIdDeregister.action = action;
|
||||
134
ui/src/routes/devices.$id.rename.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
Form,
|
||||
LoaderFunctionArgs,
|
||||
redirect,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
} from "react-router-dom";
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
|
||||
import Card from "@components/Card";
|
||||
import { CardHeader } from "@components/CardHeader";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import DashboardNavbar from "@components/Header";
|
||||
import { User } from "@/hooks/stores";
|
||||
import { checkAuth } from "@/main";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import api from "../api";
|
||||
|
||||
interface LoaderData {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
user: User;
|
||||
}
|
||||
|
||||
const action = async ({ params, request }: ActionFunctionArgs) => {
|
||||
const { id } = params;
|
||||
const { name } = Object.fromEntries(await request.formData());
|
||||
|
||||
if (!name || name === "") {
|
||||
return { message: "Please specify a name" };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.PUT(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
|
||||
name,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
} catch (e) {
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
|
||||
return redirect("/devices");
|
||||
};
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const user = await checkAuth();
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
const { device } = (await res.json()) as {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
};
|
||||
|
||||
return { device, user };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { devices: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export default function DeviceIdRename() {
|
||||
const { device, user } = useLoaderData() as LoaderData;
|
||||
const error = useActionData() as { message: string };
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<DashboardNavbar
|
||||
isLoggedIn={!!user}
|
||||
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
|
||||
userEmail={user?.email}
|
||||
picture={user?.picture}
|
||||
/>
|
||||
|
||||
<div className="w-full h-full">
|
||||
<div className="mt-4">
|
||||
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
|
||||
<div className="space-y-4">
|
||||
<LinkButton
|
||||
size="SM"
|
||||
theme="blank"
|
||||
LeadingIcon={ChevronLeftIcon}
|
||||
text="Back to Devices"
|
||||
to="/devices"
|
||||
/>
|
||||
<Card className="max-w-3xl p-6">
|
||||
<div className="space-y-4">
|
||||
<CardHeader
|
||||
headline={`Rename ${device.name || device.id}`}
|
||||
description="Properly name your device to easily identify it."
|
||||
/>
|
||||
|
||||
<Fieldset>
|
||||
<Form method="POST" className="max-w-sm space-y-4">
|
||||
<div className="relative group">
|
||||
<InputFieldWithLabel
|
||||
label="New device name"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Plex Media Server"
|
||||
size="MD"
|
||||
autoFocus
|
||||
error={error?.message.toString()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="MD"
|
||||
theme="primary"
|
||||
type="submit"
|
||||
text="Rename Device"
|
||||
textAlign="center"
|
||||
/>
|
||||
</Form>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DeviceIdRename.loader = loader;
|
||||
DeviceIdRename.action = action;
|
||||
107
ui/src/routes/devices.$id.setup.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import SimpleNavbar from "@components/SimpleNavbar";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import StepCounter from "@components/StepCounter";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
Form,
|
||||
LoaderFunctionArgs,
|
||||
redirect,
|
||||
useActionData,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { Button } from "@components/Button";
|
||||
import { checkAuth } from "@/main";
|
||||
import api from "../api";
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
await checkAuth();
|
||||
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${params.id}`, {
|
||||
method: "GET",
|
||||
mode: "cors",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
} else {
|
||||
return redirect("/devices");
|
||||
}
|
||||
};
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
// Handle form submission
|
||||
const { name, id, returnTo } = Object.fromEntries(await request.formData());
|
||||
const res = await api.PUT(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, { name });
|
||||
|
||||
if (res.ok) {
|
||||
return redirect(returnTo?.toString() ?? `/devices/${id}`);
|
||||
} else {
|
||||
return { error: "There was an error creating your device" };
|
||||
}
|
||||
};
|
||||
|
||||
export default function SetupRoute() {
|
||||
const action = useActionData() as { error?: string };
|
||||
const { id } = useParams() as { id: string };
|
||||
const [sp] = useSearchParams();
|
||||
const returnTo = sp.get("returnTo");
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<SimpleNavbar />
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-2xl -mt-32 space-y-8">
|
||||
<div className="text-center">
|
||||
<StepCounter currStepIdx={1} nSteps={2} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Let's name your device</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Name your device so you can easily identify it later. You can change
|
||||
this name at any time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Fieldset className="space-y-12">
|
||||
<Form method="POST" className="max-w-sm mx-auto space-y-4">
|
||||
<InputFieldWithLabel
|
||||
label="Device Name"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Plex Media Server"
|
||||
autoFocus
|
||||
data-1p-ignore
|
||||
autoComplete="organization"
|
||||
error={action?.error?.toString()}
|
||||
/>
|
||||
|
||||
<input type="hidden" name="id" value={id} />
|
||||
{returnTo && <input type="hidden" name="redirect" value={returnTo} />}
|
||||
<Button
|
||||
size="LG"
|
||||
theme="primary"
|
||||
fullWidth
|
||||
type="submit"
|
||||
text="Finish Setup"
|
||||
textAlign="center"
|
||||
/>
|
||||
</Form>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
SetupRoute.loader = loader;
|
||||
SetupRoute.action = action;
|
||||
501
ui/src/routes/devices.$id.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cx } from "@/cva.config";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import {
|
||||
HidState,
|
||||
UpdateState,
|
||||
useHidStore,
|
||||
User,
|
||||
useRTCStore,
|
||||
useUiStore,
|
||||
useUpdateStore,
|
||||
useVideoStore,
|
||||
useMountMediaStore,
|
||||
VideoState,
|
||||
} from "@/hooks/stores";
|
||||
import WebRTCVideo from "@components/WebRTCVideo";
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Params,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
||||
import DashboardNavbar from "@components/Header";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
import SettingsSidebar from "@/components/sidebar/settings";
|
||||
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
|
||||
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import UpdateDialog from "@components/UpdateDialog";
|
||||
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
|
||||
import api from "../api";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
import FocusTrap from "focus-trap-react";
|
||||
import OtherSessionConnectedModal from "@/components/OtherSessionConnectedModal";
|
||||
import TerminalWrapper from "../components/Terminal";
|
||||
|
||||
interface LocalLoaderResp {
|
||||
authMode: "password" | "noPassword" | null;
|
||||
}
|
||||
|
||||
interface CloudLoaderResp {
|
||||
deviceName: string;
|
||||
user: User | null;
|
||||
iceConfig: {
|
||||
iceServers: { credential?: string; urls: string | string[]; username?: string };
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface LocalDevice {
|
||||
authMode: "password" | "noPassword" | null;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
const deviceLoader = async () => {
|
||||
const res = await api
|
||||
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (!res.isSetup) return redirect("/welcome");
|
||||
|
||||
const deviceRes = await api.GET(`${import.meta.env.VITE_SIGNAL_API}/device`);
|
||||
if (deviceRes.status === 401) return redirect("/login-local");
|
||||
if (deviceRes.ok) {
|
||||
const device = (await deviceRes.json()) as LocalDevice;
|
||||
return { authMode: device.authMode };
|
||||
}
|
||||
|
||||
throw new Error("Error fetching device");
|
||||
};
|
||||
|
||||
const cloudLoader = async (params: Params<string>): Promise<CloudLoaderResp> => {
|
||||
const user = await checkAuth();
|
||||
|
||||
const iceResp = await api.POST(`${import.meta.env.VITE_CLOUD_API}/webrtc/ice_config`);
|
||||
const iceConfig = await iceResp.json();
|
||||
|
||||
const deviceResp = await api.GET(
|
||||
`${import.meta.env.VITE_CLOUD_API}/devices/${params.id}`,
|
||||
);
|
||||
|
||||
if (!deviceResp.ok) {
|
||||
if (deviceResp.status === 404) {
|
||||
throw new Response("Device not found", { status: 404 });
|
||||
}
|
||||
|
||||
throw new Error("Error fetching device");
|
||||
}
|
||||
|
||||
const { device } = (await deviceResp.json()) as {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
};
|
||||
|
||||
return { user, iceConfig, deviceName: device.name || device.id };
|
||||
};
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params);
|
||||
};
|
||||
|
||||
export default function KvmIdRoute() {
|
||||
const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp;
|
||||
|
||||
// Depending on the mode, we set the appropriate variables
|
||||
const user = "user" in loaderResp ? loaderResp.user : null;
|
||||
const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null;
|
||||
const iceConfig = "iceConfig" in loaderResp ? loaderResp.iceConfig : null;
|
||||
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
|
||||
|
||||
const params = useParams() as { id: string };
|
||||
const sidebarView = useUiStore(state => state.sidebarView);
|
||||
const [queryParams, setQueryParams] = useSearchParams();
|
||||
|
||||
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
|
||||
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
|
||||
const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
|
||||
const setPeerConnection = useRTCStore(state => state.setPeerConnection);
|
||||
const setDiskChannel = useRTCStore(state => state.setDiskChannel);
|
||||
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
|
||||
const setTransceiver = useRTCStore(state => state.setTransceiver);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
otaState,
|
||||
setOtaState,
|
||||
isUpdateDialogOpen,
|
||||
setIsUpdateDialogOpen,
|
||||
setModalView,
|
||||
} = useUpdateStore();
|
||||
|
||||
const [isOtherSessionConnectedModalOpen, setIsOtherSessionConnectedModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const sdp = useCallback(
|
||||
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
|
||||
if (!pc) return;
|
||||
if (event.candidate !== null) return;
|
||||
|
||||
try {
|
||||
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||
const res = await api.POST(`${import.meta.env.VITE_SIGNAL_API}/webrtc/session`, {
|
||||
sd,
|
||||
// When on device, we don't need to specify the device id, as it's already known
|
||||
...(isOnDevice ? {} : { id: params.id }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (isOnDevice) {
|
||||
if (res.status === 401) {
|
||||
return navigate("/login-local");
|
||||
}
|
||||
}
|
||||
|
||||
if (isInCloud) {
|
||||
// The cloud API returns a 401 if the user is not logged in
|
||||
// Most likely the session has expired
|
||||
if (res.status === 401) return navigate("/login");
|
||||
|
||||
// If can be a few things
|
||||
// - In cloud mode, the cloud api would return a 404, if the device hasn't contacted the cloud yet
|
||||
// - In device mode, the device api would timeout, the fetch would throw an error, therefore the catch block would be hit
|
||||
// Regardless, we should close the peer connection and let the useInterval handle reconnecting
|
||||
if (!res.ok) {
|
||||
pc?.close();
|
||||
console.error(`Error setting SDP - Status: ${res.status}}`, json);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pc.setRemoteDescription(
|
||||
new RTCSessionDescription(JSON.parse(atob(json.sd))),
|
||||
).catch(e => console.log(`Error setting remote description: ${e}`));
|
||||
} catch (error) {
|
||||
console.error(`Error setting SDP: ${error}`);
|
||||
pc?.close();
|
||||
}
|
||||
},
|
||||
[navigate, params.id],
|
||||
);
|
||||
|
||||
const connectWebRTC = useCallback(async () => {
|
||||
console.log("Attempting to connect WebRTC");
|
||||
const pc = new RTCPeerConnection({
|
||||
// We only use STUN or TURN servers if we're in the cloud
|
||||
...(isInCloud && iceConfig?.iceServers
|
||||
? { iceServers: [iceConfig?.iceServers] }
|
||||
: {}),
|
||||
});
|
||||
|
||||
// Set up event listeners and data channels
|
||||
pc.onconnectionstatechange = () => {
|
||||
setPeerConnectionState(pc.connectionState);
|
||||
};
|
||||
|
||||
pc.onicecandidate = event => sdp(event, pc);
|
||||
|
||||
pc.ontrack = function (event) {
|
||||
setMediaMediaStream(event.streams[0]);
|
||||
};
|
||||
|
||||
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
|
||||
|
||||
const rpcDataChannel = pc.createDataChannel("rpc");
|
||||
rpcDataChannel.onopen = () => {
|
||||
setRpcDataChannel(rpcDataChannel);
|
||||
};
|
||||
|
||||
const diskDataChannel = pc.createDataChannel("disk");
|
||||
diskDataChannel.onopen = () => {
|
||||
setDiskChannel(diskDataChannel);
|
||||
};
|
||||
|
||||
try {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
setPeerConnection(pc);
|
||||
} catch (e) {
|
||||
console.error(`Error creating offer: ${e}`);
|
||||
}
|
||||
}, [
|
||||
iceConfig?.iceServers,
|
||||
sdp,
|
||||
setDiskChannel,
|
||||
setMediaMediaStream,
|
||||
setPeerConnection,
|
||||
setPeerConnectionState,
|
||||
setRpcDataChannel,
|
||||
setTransceiver,
|
||||
]);
|
||||
|
||||
// WebRTC connection management
|
||||
useInterval(() => {
|
||||
if (
|
||||
["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// We don't want to connect if another session is connected
|
||||
if (isOtherSessionConnectedModalOpen) return;
|
||||
connectWebRTC();
|
||||
}, 3000);
|
||||
|
||||
// On boot, if the connection state is undefined, we connect to the WebRTC
|
||||
useEffect(() => {
|
||||
if (peerConnection?.connectionState === undefined) {
|
||||
connectWebRTC();
|
||||
}
|
||||
}, [connectWebRTC, peerConnection?.connectionState]);
|
||||
|
||||
// Cleanup effect
|
||||
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
|
||||
const clearCandidatePairStats = useRTCStore(state => state.clearCandidatePairStats);
|
||||
const setSidebarView = useUiStore(state => state.setSidebarView);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
peerConnection?.close();
|
||||
};
|
||||
}, [peerConnection]);
|
||||
|
||||
// For some reason, we have to have this unmount separate from the cleanup effect above
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearInboundRtpStats();
|
||||
clearCandidatePairStats();
|
||||
setSidebarView(null);
|
||||
setPeerConnection(null);
|
||||
};
|
||||
}, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]);
|
||||
|
||||
// TURN server usage detection
|
||||
useEffect(() => {
|
||||
if (peerConnection?.connectionState !== "connected") return;
|
||||
const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState();
|
||||
|
||||
const lastLocalStat = Array.from(localCandidateStats).pop();
|
||||
if (!lastLocalStat?.length) return;
|
||||
const localCandidateIsUsingTurn = lastLocalStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
|
||||
|
||||
const lastRemoteStat = Array.from(remoteCandidateStats).pop();
|
||||
if (!lastRemoteStat?.length) return;
|
||||
const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
|
||||
|
||||
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
|
||||
}, [peerConnection?.connectionState, setIsTurnServerInUse]);
|
||||
|
||||
// TURN server usage reporting
|
||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
||||
const lastBytesReceived = useRef<number>(0);
|
||||
const lastBytesSent = useRef<number>(0);
|
||||
|
||||
useInterval(() => {
|
||||
// Don't report usage if we're not using the turn server
|
||||
if (!isTurnServerInUse) return;
|
||||
const { candidatePairStats } = useRTCStore.getState();
|
||||
|
||||
const lastCandidatePair = Array.from(candidatePairStats).pop();
|
||||
const report = lastCandidatePair?.[1];
|
||||
if (!report) return;
|
||||
|
||||
let bytesReceivedDelta = 0;
|
||||
let bytesSentDelta = 0;
|
||||
|
||||
if (report.bytesReceived) {
|
||||
bytesReceivedDelta = report.bytesReceived - lastBytesReceived.current;
|
||||
lastBytesReceived.current = report.bytesReceived;
|
||||
}
|
||||
|
||||
if (report.bytesSent) {
|
||||
bytesSentDelta = report.bytesSent - lastBytesSent.current;
|
||||
lastBytesSent.current = report.bytesSent;
|
||||
}
|
||||
|
||||
// Fire and forget
|
||||
api.POST(`${import.meta.env.VITE_CLOUD_API}/webrtc/turn_activity`, {
|
||||
bytesReceived: bytesReceivedDelta,
|
||||
bytesSent: bytesSentDelta,
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
const setUsbState = useHidStore(state => state.setUsbState);
|
||||
const setHdmiState = useVideoStore(state => state.setHdmiState);
|
||||
|
||||
const [hasUpdated, setHasUpdated] = useState(false);
|
||||
function onJsonRpcRequest(resp: JsonRpcRequest) {
|
||||
if (resp.method === "otherSessionConnected") {
|
||||
console.log("otherSessionConnected", resp.params);
|
||||
setIsOtherSessionConnectedModalOpen(true);
|
||||
}
|
||||
|
||||
if (resp.method === "usbState") {
|
||||
setUsbState(resp.params as unknown as HidState["usbState"]);
|
||||
}
|
||||
|
||||
if (resp.method === "videoInputState") {
|
||||
setHdmiState(resp.params as Parameters<VideoState["setHdmiState"]>[0]);
|
||||
}
|
||||
|
||||
if (resp.method === "otaState") {
|
||||
const otaState = resp.params as UpdateState["otaState"];
|
||||
setOtaState(otaState);
|
||||
|
||||
if (otaState.updating === true) {
|
||||
setHasUpdated(true);
|
||||
}
|
||||
|
||||
if (hasUpdated && otaState.updating === false) {
|
||||
setHasUpdated(false);
|
||||
|
||||
if (otaState.error) {
|
||||
setModalView("error");
|
||||
setIsUpdateDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.search = "";
|
||||
currentUrl.searchParams.set("updateSuccess", "true");
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
const [send] = useJsonRpc(onJsonRpcRequest);
|
||||
|
||||
useEffect(() => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
send("getVideoState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
|
||||
});
|
||||
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
window.send = send;
|
||||
|
||||
// When the update is successful, we need to refresh the client javascript and show a success modal
|
||||
useEffect(() => {
|
||||
if (queryParams.get("updateSuccess")) {
|
||||
setModalView("updateCompleted");
|
||||
setIsUpdateDialogOpen(true);
|
||||
setQueryParams({});
|
||||
}
|
||||
}, [queryParams, setIsUpdateDialogOpen, setModalView, setQueryParams]);
|
||||
|
||||
const diskChannel = useRTCStore(state => state.diskChannel)!;
|
||||
const file = useMountMediaStore(state => state.localFile)!;
|
||||
useEffect(() => {
|
||||
if (!diskChannel || !file) return;
|
||||
diskChannel.onmessage = async e => {
|
||||
console.log("Received", e.data);
|
||||
const data = JSON.parse(e.data);
|
||||
const blob = file.slice(data.start, data.end);
|
||||
const buf = await blob.arrayBuffer();
|
||||
const header = new ArrayBuffer(16);
|
||||
const headerView = new DataView(header);
|
||||
headerView.setBigUint64(0, BigInt(data.start), false); // start offset, big-endian
|
||||
headerView.setBigUint64(8, BigInt(buf.byteLength), false); // length, big-endian
|
||||
const fullData = new Uint8Array(header.byteLength + buf.byteLength);
|
||||
fullData.set(new Uint8Array(header), 0);
|
||||
fullData.set(new Uint8Array(buf), header.byteLength);
|
||||
diskChannel.send(fullData);
|
||||
};
|
||||
}, [diskChannel, file]);
|
||||
|
||||
// System update
|
||||
const disableKeyboardFocusTrap = useUiStore(state => state.disableVideoFocusTrap);
|
||||
return (
|
||||
<>
|
||||
<Transition show={!isUpdateDialogOpen && otaState.updating}>
|
||||
<div className="fixed inset-0 z-10 flex items-start justify-center w-full h-full max-w-xl mx-auto translate-y-8 pointer-events-none">
|
||||
<div className="transition duration-1000 ease-in data-[closed]:opacity-0">
|
||||
<UpdateInProgressStatusCard
|
||||
setIsUpdateDialogOpen={setIsUpdateDialogOpen}
|
||||
setModalView={setModalView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div className="relative h-full">
|
||||
<FocusTrap
|
||||
paused={disableKeyboardFocusTrap}
|
||||
focusTrapOptions={{
|
||||
allowOutsideClick: true,
|
||||
escapeDeactivates: false,
|
||||
fallbackFocus: "#videoFocusTrap",
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0">
|
||||
<button className="absolute top-0" tabIndex={-1} id="videoFocusTrap" />
|
||||
</div>
|
||||
</FocusTrap>
|
||||
<div className="grid h-full select-none grid-rows-headerBody">
|
||||
<DashboardNavbar
|
||||
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
|
||||
showConnectionStatus={true}
|
||||
isLoggedIn={authMode === "password" || !!user}
|
||||
userEmail={user?.email}
|
||||
picture={user?.picture}
|
||||
kvmName={deviceName || "JetKVM Device"}
|
||||
/>
|
||||
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<WebRTCVideo />
|
||||
<SidebarContainer sidebarView={sidebarView} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateDialog open={isUpdateDialogOpen} setOpen={setIsUpdateDialogOpen} />
|
||||
<OtherSessionConnectedModal
|
||||
open={isOtherSessionConnectedModalOpen}
|
||||
setOpen={state => {
|
||||
if (state === false) {
|
||||
connectWebRTC();
|
||||
}
|
||||
|
||||
// It takes some time for the WebRTC connection to be established, so we wait a bit before closing the modal
|
||||
setTimeout(() => {
|
||||
setIsOtherSessionConnectedModalOpen(state);
|
||||
}, 1000);
|
||||
}}
|
||||
/>
|
||||
<TerminalWrapper />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContainer({ sidebarView }: { sidebarView: string | null }) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex shrink-0 border-l border-l-slate-800/20 transition-all duration-500 ease-in-out dark:border-l-slate-300/20",
|
||||
{ "border-x-transparent": !sidebarView },
|
||||
)}
|
||||
style={{ width: sidebarView ? "493px" : 0 }}
|
||||
>
|
||||
<div className="relative w-[493px] shrink-0">
|
||||
<Transition show={sidebarView === "system"} unmount={false}>
|
||||
<div className="absolute inset-0">
|
||||
<SettingsSidebar />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition show={sidebarView === "connection-stats"} unmount={false}>
|
||||
<div className="absolute inset-0">
|
||||
<ConnectionStatsSidebar />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
KvmIdRoute.loader = loader;
|
||||
43
ui/src/routes/devices.already-adopted.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import SimpleNavbar from "@/components/SimpleNavbar";
|
||||
import Container from "@/components/Container";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
|
||||
export default function DevicesAlreadyAdopted() {
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<SimpleNavbar />
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-2xl -mt-16 space-y-8">
|
||||
<div className="space-y-4 text-center">
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Device Already Registered</h1>
|
||||
<p className="text-lg text-slate-600 dark:text-slate-400">
|
||||
This device is currently registered to another user in our cloud
|
||||
dashboard.
|
||||
</p>
|
||||
<p className="mt-4 text-lg text-slate-600 dark:text-slate-400">
|
||||
If you're the new owner, please ask the previous owner to de-register
|
||||
the device from their account in the cloud dashboard. If you believe
|
||||
this is an error, contact our support team for assistance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<LinkButton
|
||||
to="/devices"
|
||||
size="LG"
|
||||
theme="primary"
|
||||
text="Return to Dashboard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
ui/src/routes/devices.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useLoaderData, useRevalidator } from "react-router-dom";
|
||||
|
||||
import DashboardNavbar from "@components/Header";
|
||||
import { LinkButton } from "@components/Button";
|
||||
import KvmCard from "@components/KvmCard";
|
||||
import useInterval from "@/hooks/useInterval";
|
||||
import { checkAuth } from "@/main";
|
||||
import { User } from "@/hooks/stores";
|
||||
import EmptyCard from "@components/EmptyCard";
|
||||
import { LuMonitorSmartphone } from "react-icons/lu";
|
||||
import { ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||
|
||||
interface LoaderData {
|
||||
devices: { id: string; name: string; online: boolean; lastSeen: string }[];
|
||||
user: User;
|
||||
}
|
||||
|
||||
export const loader = async () => {
|
||||
const user = await checkAuth();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
const { devices } = await res.json();
|
||||
return { devices, user };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { devices: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export default function DevicesRoute() {
|
||||
const { devices, user } = useLoaderData() as LoaderData;
|
||||
const revalidate = useRevalidator();
|
||||
useInterval(revalidate.revalidate, 4000);
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div className="grid h-full select-none grid-rows-headerBody">
|
||||
<DashboardNavbar
|
||||
isLoggedIn={!!user}
|
||||
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
|
||||
userEmail={user?.email}
|
||||
picture={user?.picture}
|
||||
/>
|
||||
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
|
||||
<div className="flex items-center justify-between pb-4 mt-8 border-b border-b-slate-800/20 dark:border-b-slate-300/20">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-black dark:text-white">
|
||||
Cloud KVMs
|
||||
</h1>
|
||||
<p className="text-base text-slate-700 dark:text-slate-400">
|
||||
Manage your cloud KVMs and connect to them securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{devices.length === 0 ? (
|
||||
<div className="max-w-3xl">
|
||||
<EmptyCard
|
||||
IconElm={LuMonitorSmartphone}
|
||||
headline="No devices found"
|
||||
description="You don't have any devices with enabled JetKVM Cloud yet."
|
||||
BtnElm={
|
||||
<LinkButton
|
||||
to="https://jetkvm.com/docs/networking/remote-access"
|
||||
size="SM"
|
||||
theme="primary"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
text="Learn more"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{devices.map(x => {
|
||||
return (
|
||||
<KvmCard
|
||||
key={x.id}
|
||||
id={x.id}
|
||||
title={x.name ?? x.id}
|
||||
lastSeen={x.lastSeen ? new Date(x.lastSeen) : null}
|
||||
online={x.online}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
ui/src/routes/login-local.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import SimpleNavbar from "@components/SimpleNavbar";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { Button } from "@components/Button";
|
||||
import { useState } from "react";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import api from "../api";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
import ExtLink from "../components/ExtLink";
|
||||
|
||||
const loader = async () => {
|
||||
const res = await api
|
||||
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (!res.isSetup) return redirect("/welcome");
|
||||
|
||||
const deviceRes = await api.GET(`${import.meta.env.VITE_SIGNAL_API}/device`);
|
||||
if (deviceRes.ok) return redirect("/");
|
||||
return null;
|
||||
};
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const password = formData.get("password");
|
||||
|
||||
try {
|
||||
const response = await api.POST(
|
||||
`${import.meta.env.VITE_SIGNAL_API}/auth/login-local`,
|
||||
{
|
||||
password,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return redirect("/");
|
||||
} else {
|
||||
return { error: "Invalid password" };
|
||||
}
|
||||
} catch (error) {
|
||||
return { error: "An error occurred while logging in" };
|
||||
}
|
||||
};
|
||||
|
||||
export default function LoginLocalRoute() {
|
||||
const actionData = useActionData() as { error?: string; success?: boolean };
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<SimpleNavbar />
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-2xl -mt-32 space-y-8">
|
||||
<div className="flex items-center justify-center">
|
||||
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Welcome back to JetKVM</h1>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||
Enter your password to access your JetKVM.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Fieldset className="space-y-12">
|
||||
<Form method="POST" className="max-w-sm mx-auto space-y-4">
|
||||
<div className="space-y-4">
|
||||
<InputFieldWithLabel
|
||||
label="Password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
autoFocus
|
||||
error={actionData?.error}
|
||||
TrailingElm={
|
||||
showPassword ? (
|
||||
<div
|
||||
onClick={() => setShowPassword(false)}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<LuEye className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setShowPassword(true)}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<LuEyeOff className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="LG"
|
||||
theme="primary"
|
||||
fullWidth
|
||||
type="submit"
|
||||
text="Log In"
|
||||
textAlign="center"
|
||||
/>
|
||||
|
||||
<div className="flex justify-start mt-4 text-xs text-slate-500 dark:text-slate-400">
|
||||
<ExtLink
|
||||
href="https://jetkvm.com/docs/networking/local-access#reset-password"
|
||||
className="hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</ExtLink>
|
||||
</div>
|
||||
</Form>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LoginLocalRoute.loader = loader;
|
||||
LoginLocalRoute.action = action;
|
||||
33
ui/src/routes/login.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import AuthLayout from "@components/AuthLayout";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
|
||||
export default function LoginRoute() {
|
||||
const [sq] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const deviceId = sq.get("deviceId") || location.state?.deviceId;
|
||||
|
||||
if (deviceId) {
|
||||
return (
|
||||
<AuthLayout
|
||||
showCounter={true}
|
||||
title="Connect your JetKVM to the cloud"
|
||||
description="Unlock remote access and advanced features for your device"
|
||||
action="Log in & Connect device"
|
||||
// Header CTA
|
||||
cta="Don't have an account?"
|
||||
ctaHref={`/signup?${sq.toString()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Log in to your JetKVM account"
|
||||
description="Log in to access and manage your devices securely"
|
||||
action="Log in"
|
||||
// Header CTA
|
||||
cta="New to JetKVM?"
|
||||
ctaHref={`/signup?${sq.toString()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
ui/src/routes/signup.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import AuthLayout from "@components/AuthLayout";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
|
||||
export default function SignupRoute() {
|
||||
const [sq] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const deviceId = sq.get("deviceId") || location.state?.deviceId;
|
||||
|
||||
if (deviceId) {
|
||||
return (
|
||||
<AuthLayout
|
||||
showCounter={true}
|
||||
title="Connect your JetKVM to the cloud"
|
||||
description="Unlock remote access and advanced features for your device."
|
||||
action="Signup & Connect device"
|
||||
cta="Already have an account?"
|
||||
ctaHref={`/login?${sq.toString()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Create your JetKVM account"
|
||||
description="Create your account and start managing your devices with ease."
|
||||
action="Create Account"
|
||||
// Header CTA
|
||||
cta="Already have an account?"
|
||||
ctaHref={`/login?${sq.toString()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
156
ui/src/routes/welcome-local.mode.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
||||
import { Button } from "@components/Button";
|
||||
import { useState } from "react";
|
||||
import { GridCard } from "../components/Card";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import { cx } from "../cva.config";
|
||||
import api from "../api";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
|
||||
const loader = async () => {
|
||||
const res = await api
|
||||
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (res.isSetup) return redirect("/login-local");
|
||||
return null;
|
||||
};
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const localAuthMode = formData.get("localAuthMode");
|
||||
if (!localAuthMode) return { error: "Please select an authentication mode" };
|
||||
|
||||
if (localAuthMode === "password") {
|
||||
return redirect("/welcome/password");
|
||||
}
|
||||
|
||||
if (localAuthMode === "noPassword") {
|
||||
try {
|
||||
await api.POST(`${import.meta.env.VITE_SIGNAL_API}/device/setup`, {
|
||||
localAuthMode,
|
||||
});
|
||||
return redirect("/");
|
||||
} catch (error) {
|
||||
console.error("Error setting authentication mode:", error);
|
||||
return { error: "An error occurred while setting the authentication mode" };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "Invalid authentication mode" };
|
||||
};
|
||||
|
||||
export default function WelcomeLocalModeRoute() {
|
||||
const actionData = useActionData() as { error?: string };
|
||||
const [selectedMode, setSelectedMode] = useState<"password" | "noPassword" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen">
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-xl space-y-8">
|
||||
<div className="flex items-center justify-center opacity-0 animate-fadeIn">
|
||||
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="space-y-2 text-center opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Local Authentication Method</h1>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||
Select how you{"'"}d like to secure your JetKVM device locally.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form method="POST" className="space-y-8">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-6 opacity-0 animate-fadeIn sm:grid-cols-2"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
{["password", "noPassword"].map(mode => (
|
||||
<GridCard
|
||||
key={mode}
|
||||
cardClassName={cx("transition-all duration-100", {
|
||||
"!outline-blue-700 !outline-2": selectedMode === mode,
|
||||
"hover:!outline-blue-700": selectedMode !== mode,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="relative flex flex-col items-center p-6 cursor-pointer select-none"
|
||||
onClick={() => setSelectedMode(mode as "password" | "noPassword")}
|
||||
>
|
||||
<div className="space-y-0 text-center">
|
||||
<h3 className="text-base font-bold text-black dark:text-white">
|
||||
{mode === "password" ? "Password protected" : "No Password"}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-center text-gray-600 dark:text-gray-400">
|
||||
{mode === "password"
|
||||
? "Secure your device with a password for added protection."
|
||||
: "Quick access without password authentication."}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="radio"
|
||||
name="localAuthMode"
|
||||
value={mode}
|
||||
checked={selectedMode === mode}
|
||||
onChange={() => {
|
||||
setSelectedMode(mode as "password" | "noPassword");
|
||||
}}
|
||||
className="absolute w-4 h-4 text-blue-600 right-2 top-2"
|
||||
/>
|
||||
</div>
|
||||
</GridCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<p
|
||||
className="text-sm text-center text-red-600 opacity-0 dark:text-red-400 animate-fadeIn"
|
||||
style={{ animationDelay: "500ms" }}
|
||||
>
|
||||
{actionData.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="max-w-sm mx-auto opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "500ms" }}
|
||||
>
|
||||
<Button
|
||||
size="LG"
|
||||
theme="primary"
|
||||
fullWidth
|
||||
type="submit"
|
||||
text="Continue"
|
||||
textAlign="center"
|
||||
disabled={!selectedMode}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<p
|
||||
className="max-w-md mx-auto text-xs text-center opacity-0 animate-fadeIn text-slate-500 dark:text-slate-400"
|
||||
style={{ animationDelay: "600ms" }}
|
||||
>
|
||||
You can always change your authentication method later in the settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
WelcomeLocalModeRoute.action = action;
|
||||
WelcomeLocalModeRoute.loader = loader;
|
||||
168
ui/src/routes/welcome-local.password.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { Button } from "@components/Button";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import api from "../api";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
|
||||
const loader = async () => {
|
||||
const res = await api
|
||||
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (res.isSetup) return redirect("/login-local");
|
||||
return null;
|
||||
};
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const password = formData.get("password");
|
||||
const confirmPassword = formData.get("confirmPassword");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return { error: "Passwords do not match" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.POST(`${import.meta.env.VITE_SIGNAL_API}/device/setup`, {
|
||||
localAuthMode: "password",
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return redirect("/");
|
||||
} else {
|
||||
return { error: "Failed to set password" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error setting password:", error);
|
||||
return { error: "An error occurred while setting the password" };
|
||||
}
|
||||
};
|
||||
|
||||
export default function WelcomeLocalPasswordRoute() {
|
||||
const actionData = useActionData() as { error?: string };
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Don't focus immediately, let the animation finish
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}, 1000); // 1 second delay
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen">
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<div className="flex items-center justify-center opacity-0 animate-fadeIn">
|
||||
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="space-y-2 text-center opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Set a Password</h1>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||
Create a strong password to secure your JetKVM device locally.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Fieldset className="space-y-12">
|
||||
<Form method="POST" className="max-w-sm mx-auto space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<InputFieldWithLabel
|
||||
label="Password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
placeholder="Enter a password"
|
||||
autoComplete="new-password"
|
||||
ref={passwordInputRef}
|
||||
TrailingElm={
|
||||
showPassword ? (
|
||||
<div
|
||||
onClick={() => setShowPassword(false)}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<LuEye className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setShowPassword(true)}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<LuEyeOff className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<InputFieldWithLabel
|
||||
label="Confirm Password"
|
||||
autoComplete="new-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm your password"
|
||||
error={actionData?.error}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionData?.error && <p className="text-sm text-red-600">{}</p>}
|
||||
|
||||
<div
|
||||
className="opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "600ms" }}
|
||||
>
|
||||
<Button
|
||||
size="LG"
|
||||
theme="primary"
|
||||
fullWidth
|
||||
type="submit"
|
||||
text="Set Password"
|
||||
textAlign="center"
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</Fieldset>
|
||||
|
||||
<p
|
||||
className="max-w-md text-xs text-center opacity-0 animate-fadeIn text-slate-500 dark:text-slate-400"
|
||||
style={{ animationDelay: "800ms" }}
|
||||
>
|
||||
This password will be used to secure your device data and protect against
|
||||
unauthorized access.{" "}
|
||||
<span className="font-bold">All data remains on your local device.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
WelcomeLocalPasswordRoute.action = action;
|
||||
WelcomeLocalPasswordRoute.loader = loader;
|
||||
104
ui/src/routes/welcome-local.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import { LinkButton } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import DeviceImage from "@/assets/jetkvm-device-still.png";
|
||||
import LogoMark from "@/assets/logo-mark.png";
|
||||
import { cx } from "cva";
|
||||
import api from "../api";
|
||||
import { redirect } from "react-router-dom";
|
||||
|
||||
export interface DeviceStatus {
|
||||
isSetup: boolean;
|
||||
}
|
||||
|
||||
const loader = async () => {
|
||||
const res = await api
|
||||
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (res.isSetup) return redirect("/login-local");
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function WelcomeRoute() {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.src = DeviceImage;
|
||||
img.onload = () => setImageLoaded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen">
|
||||
{imageLoaded && (
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-3xl text-center">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center opacity-0 animate-fadeIn animation-delay-1000">
|
||||
<img src={LogoWhiteIcon} alt="JetKVM Logo" className="h-[32px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="JetKVM Logo" className="h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="space-y-1 opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "1500ms" }}
|
||||
>
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">
|
||||
Welcome to JetKVM
|
||||
</h1>
|
||||
<p className="text-lg font-medium text-slate-600 dark:text-slate-400">
|
||||
Control any computer remotely
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="!-mt-2 -ml-6 flex items-center justify-center">
|
||||
<img
|
||||
src={DeviceImage}
|
||||
alt="JetKVM Device"
|
||||
className="animation-delay-0 max-w-md scale-[0.98] animate-fadeInScaleFloat opacity-0 transition-all duration-1000 ease-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-8 space-y-4">
|
||||
<p
|
||||
style={{ animationDelay: "2000ms" }}
|
||||
className="max-w-lg mx-auto text-lg opacity-0 animate-fadeIn text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
JetKVM combines powerful hardware with intuitive software to provide a
|
||||
seamless remote control experience.
|
||||
</p>
|
||||
<div
|
||||
style={{ animationDelay: "2300ms" }}
|
||||
className="opacity-0 animate-fadeIn"
|
||||
>
|
||||
<LinkButton
|
||||
size="LG"
|
||||
theme="light"
|
||||
text="Set up your JetKVM"
|
||||
LeadingIcon={({ className }) => (
|
||||
<img src={LogoMark} className={cx(className, "mr-1.5 !h-5")} />
|
||||
)}
|
||||
textAlign="center"
|
||||
to="/welcome/mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
WelcomeRoute.loader = loader;
|
||||
234
ui/src/utils.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
export const formatters = {
|
||||
date: (date: Date, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
...(options || {}),
|
||||
}).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);
|
||||
},
|
||||
|
||||
truncateMiddle: (str: string | null | undefined, maxLength: number): string => {
|
||||
if (!str) return "";
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
const halfLength = Math.floor(maxLength / 2);
|
||||
const firstPart = str.slice(0, halfLength - 1);
|
||||
const lastPart = str.slice(-halfLength + 2);
|
||||
|
||||
return `${firstPart}...${lastPart}`;
|
||||
},
|
||||
};
|
||||
|
||||
export const VIDEO = new Blob(
|
||||
[
|
||||
new Uint8Array([
|
||||
0, 0, 0, 28, 102, 116, 121, 112, 105, 115, 111, 109, 0, 0, 2, 0, 105, 115, 111, 109,
|
||||
105, 115, 111, 50, 109, 112, 52, 49, 0, 0, 0, 8, 102, 114, 101, 101, 0, 0, 2, 239,
|
||||
109, 100, 97, 116, 33, 16, 5, 32, 164, 27, 255, 192, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 55, 167, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 33, 16, 5, 32, 164, 27, 255,
|
||||
192, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 55, 167, 128, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 112, 0, 0, 2, 194, 109, 111, 111, 118, 0, 0, 0, 108, 109, 118, 104, 100, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 232, 0, 0, 0, 47, 0, 1, 0, 0, 1, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 1, 236, 116, 114, 97, 107, 0,
|
||||
0, 0, 92, 116, 107, 104, 100, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0,
|
||||
0, 0, 0, 0, 0, 47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 101, 100, 116, 115, 0, 0, 0, 28, 101,
|
||||
108, 115, 116, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 47, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
|
||||
100, 109, 100, 105, 97, 0, 0, 0, 32, 109, 100, 104, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 172, 68, 0, 0, 8, 0, 85, 196, 0, 0, 0, 0, 0, 45, 104, 100, 108, 114,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 115, 111, 117, 110, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 83,
|
||||
111, 117, 110, 100, 72, 97, 110, 100, 108, 101, 114, 0, 0, 0, 1, 15, 109, 105, 110,
|
||||
102, 0, 0, 0, 16, 115, 109, 104, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 100, 105,
|
||||
110, 102, 0, 0, 0, 28, 100, 114, 101, 102, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 12, 117,
|
||||
114, 108, 32, 0, 0, 0, 1, 0, 0, 0, 211, 115, 116, 98, 108, 0, 0, 0, 103, 115, 116,
|
||||
115, 100, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 87, 109, 112, 52, 97, 0, 0, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 16, 0, 0, 0, 0, 172, 68, 0, 0, 0, 0, 0, 51,
|
||||
101, 115, 100, 115, 0, 0, 0, 0, 3, 128, 128, 128, 34, 0, 2, 0, 4, 128, 128, 128, 20,
|
||||
64, 21, 0, 0, 0, 0, 1, 244, 0, 0, 1, 243, 249, 5, 128, 128, 128, 2, 18, 16, 6, 128,
|
||||
128, 128, 1, 2, 0, 0, 0, 24, 115, 116, 116, 115, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2,
|
||||
0, 0, 4, 0, 0, 0, 0, 28, 115, 116, 115, 99, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0,
|
||||
0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 28, 115, 116, 115, 122, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 2, 0, 0, 1, 115, 0, 0, 1, 116, 0, 0, 0, 20, 115, 116, 99, 111, 0, 0, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0, 44, 0, 0, 0, 98, 117, 100, 116, 97, 0, 0, 0, 90, 109, 101, 116, 97,
|
||||
0, 0, 0, 0, 0, 0, 0, 33, 104, 100, 108, 114, 0, 0, 0, 0, 0, 0, 0, 0, 109, 100, 105,
|
||||
114, 97, 112, 112, 108, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 45, 105, 108, 115, 116,
|
||||
0, 0, 0, 37, 169, 116, 111, 111, 0, 0, 0, 29, 100, 97, 116, 97, 0, 0, 0, 1, 0, 0, 0,
|
||||
0, 76, 97, 118, 102, 53, 54, 46, 52, 48, 46, 49, 48, 49,
|
||||
]),
|
||||
],
|
||||
{ type: "video/mp4" },
|
||||
);
|
||||
|
||||
export function canAutoPlayVideo(
|
||||
muted = true,
|
||||
timeout = 250,
|
||||
inline = false,
|
||||
): Promise<{ result: boolean; error: Error | null }> {
|
||||
const videoElm = document.createElement("video");
|
||||
videoElm.muted = muted;
|
||||
if (muted) {
|
||||
videoElm.setAttribute("muted", "muted");
|
||||
}
|
||||
|
||||
if (inline) {
|
||||
videoElm.setAttribute("playsinline", "playsinline");
|
||||
}
|
||||
|
||||
videoElm.src = URL.createObjectURL(VIDEO);
|
||||
|
||||
return new Promise(resolve => {
|
||||
const playResult = videoElm.play();
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
sendOutput(false, new Error(`Timeout ${timeout} ms has been reached`));
|
||||
}, timeout);
|
||||
|
||||
const sendOutput = (result: boolean, error: Error | null = null) => {
|
||||
videoElm.remove();
|
||||
videoElm.src = "";
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
resolve({ result, error });
|
||||
};
|
||||
|
||||
if (playResult !== undefined) {
|
||||
playResult
|
||||
.then(() => {
|
||||
sendOutput(true);
|
||||
})
|
||||
.catch(playError => {
|
||||
sendOutput(false, playError);
|
||||
});
|
||||
} else {
|
||||
sendOutput(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function isMac() {
|
||||
return !!/mac/i.exec(navigator.platform);
|
||||
}
|
||||
|
||||
export function isWindows() {
|
||||
return !!/win/i.exec(navigator.platform);
|
||||
}
|
||||
|
||||
export function isIOS() {
|
||||
return (
|
||||
!!/ipad/i.exec(navigator.platform) ||
|
||||
!!/iphone/i.exec(navigator.platform) ||
|
||||
!!/ipod/i.exec(navigator.platform)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAndroid() {
|
||||
/* Android sets navigator.platform to Linux :/ */
|
||||
return !!navigator.userAgent.match("Android ");
|
||||
}
|
||||
|
||||
export function isChromeOS() {
|
||||
/* ChromeOS sets navigator.platform to Linux :/ */
|
||||
return !!navigator.userAgent.match(" CrOS ");
|
||||
}
|
||||
31
ui/src/webrtc.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
interface RTCIceCandidateStats {
|
||||
address?: string; // The address of the candidate. Could be IPv4, IPv6, or a fully-qualified domain name.
|
||||
candidateType: "host" | "srflx" | "prflx" | "relay"; // The type of candidate (host, srflx, prflx, relay).
|
||||
foundation: string; // A unique identifier for this candidate, used for network performance optimization.
|
||||
id: string; // A unique identifier for this object.
|
||||
port?: number; // The network port used by the candidate.
|
||||
priority?: number; // The priority of the candidate.
|
||||
protocol?: string; // The protocol used by the candidate (tcp or udp).
|
||||
relatedAddress?: string; // The related address of the candidate.
|
||||
relatedPort?: number; // The related port of the candidate.
|
||||
sdpMid?: string; // The media stream identification for the candidate.
|
||||
sdpMLineIndex?: number; // The index of the media line for the candidate.
|
||||
tcpType?: string; // The type of TCP candidate (active, passive, or so).
|
||||
type: "local-candidate" | "remote-candidate"; // The type of the statistics object.
|
||||
usernameFragment: string; // The username fragment used for message authentication.
|
||||
timestamp: number; // The timestamp at which the sample was taken.
|
||||
}
|
||||
|
||||
interface RTCDataChannelStats {
|
||||
bytesReceived: number;
|
||||
bytesSent: number;
|
||||
dataChannelIdentifier: number;
|
||||
id: string;
|
||||
label: string;
|
||||
messagesReceived: number;
|
||||
messagesSent: number;
|
||||
protocol: string;
|
||||
state: string;
|
||||
timestamp: number;
|
||||
type: string;
|
||||
}
|
||||