393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
import { useState, type ReactNode } from "react";
|
|
import {
|
|
AlertTriangle,
|
|
Eye,
|
|
EyeOff,
|
|
Monitor,
|
|
MousePointerClick,
|
|
Plug,
|
|
ShieldCheck,
|
|
Wifi,
|
|
Wrench,
|
|
} from "lucide-react";
|
|
import { useVncConsole, type VncConnectionState } from "../hooks/useVncConsole";
|
|
|
|
type ConsolePageProps = {
|
|
theme: "dark" | "light";
|
|
};
|
|
|
|
const RADIUS = "rounded-[5px]";
|
|
|
|
export function ConsolePage({ theme }: ConsolePageProps) {
|
|
const isDark = theme === "dark";
|
|
|
|
const vnc = useVncConsole({
|
|
websocketUrl: "ws://localhost:18450/ws/vnc",
|
|
defaultHost: "198.19.0.176",
|
|
defaultPort: 5900,
|
|
});
|
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
|
const connectionLabel = getConnectionLabel(vnc.state);
|
|
const hasFrame = vnc.frameSize.width > 0 && vnc.frameSize.height > 0;
|
|
const frameAspectRatio = hasFrame
|
|
? `${vnc.frameSize.width} / ${vnc.frameSize.height}`
|
|
: "800 / 480";
|
|
|
|
return (
|
|
<div className={isDark ? "h-full min-h-0 text-slate-100" : "h-full min-h-0 text-slate-950"}>
|
|
<section className="grid h-full min-h-0 gap-4 xl:grid-cols-[300px_minmax(0,1fr)]">
|
|
<aside className={panelClass(isDark)}>
|
|
<div className="mb-5">
|
|
<p className="text-xs font-black uppercase tracking-[0.20em] text-slate-500">
|
|
ACESSO REMOTO
|
|
</p>
|
|
<h2 className={panelTitleClass(isDark)}>
|
|
Controlador
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-2">
|
|
<StatusCard
|
|
theme={theme}
|
|
icon={<Wifi className="h-4 w-4" />}
|
|
title="Estado da sessão"
|
|
value={connectionLabel}
|
|
color={vnc.connected ? "green" : vnc.connecting ? "blue" : "purple"}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-5 space-y-4">
|
|
<Field
|
|
theme={theme}
|
|
label="Host"
|
|
value={vnc.host}
|
|
onChange={vnc.setHost}
|
|
disabled={vnc.connecting || vnc.connected}
|
|
/>
|
|
|
|
<Field
|
|
theme={theme}
|
|
label="Porta"
|
|
value={String(vnc.port)}
|
|
onChange={(value) => {
|
|
const parsed = Number(value);
|
|
vnc.setPort(Number.isFinite(parsed) ? parsed : 5900);
|
|
}}
|
|
disabled={vnc.connecting || vnc.connected}
|
|
/>
|
|
|
|
<Field
|
|
theme={theme}
|
|
label="Password"
|
|
type={passwordVisible ? "text" : "password"}
|
|
value={vnc.password}
|
|
onChange={vnc.setPassword}
|
|
disabled={vnc.connecting || vnc.connected}
|
|
rightElement={
|
|
<button
|
|
type="button"
|
|
onClick={() => setPasswordVisible((current) => !current)}
|
|
disabled={vnc.connecting || vnc.connected}
|
|
className={
|
|
isDark
|
|
? "grid h-8 w-8 place-items-center rounded-[5px] text-slate-400 transition hover:bg-white/5 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
: "grid h-8 w-8 place-items-center rounded-[5px] text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-40"
|
|
}
|
|
aria-label={passwordVisible ? "Esconder password" : "Mostrar password"}
|
|
>
|
|
{passwordVisible ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={vnc.connecting}
|
|
onClick={() => (vnc.connected ? vnc.disconnect() : vnc.connect())}
|
|
className="mt-5 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-[5px] bg-[#4FD1C5] text-sm font-black text-[#031014] transition hover:bg-[#5FE1D5] disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<Plug className="h-4 w-4" />
|
|
{vnc.connected ? "Desligar sessão" : vnc.connecting ? "A ligar..." : "Iniciar sessão"}
|
|
</button>
|
|
|
|
{vnc.error && (
|
|
<div className="mt-4 flex gap-3 rounded-[5px] border border-red-500/25 bg-red-500/10 p-4 text-xs font-semibold leading-5 text-red-200">
|
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<span>{vnc.error}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-6 space-y-3">
|
|
<SmallInfo theme={theme} icon={<ShieldCheck className="h-4 w-4" />} label="Modo" value="LAN direta" />
|
|
<SmallInfo theme={theme} icon={<Wrench className="h-4 w-4" />} label="Controlador" value={vnc.host} />
|
|
<SmallInfo theme={theme} icon={<MousePointerClick className="h-4 w-4" />} label="Interação" value="Cliques ativos" />
|
|
</div>
|
|
</aside>
|
|
|
|
<section className={`${panelClass(isDark)} min-w-0 p-0`}>
|
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-white/10 px-5">
|
|
<div>
|
|
<h2 className={panelTitleClass(isDark)}>
|
|
Ecrã do controlador
|
|
</h2>
|
|
<p className="mt-0.5 text-xs font-semibold text-slate-500">
|
|
{vnc.lastFrameAt ? `Último frame: ${vnc.lastFrameAt}` : "A aguardar imagem da consola"}
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black">
|
|
{!hasFrame && (
|
|
<div className="flex max-w-md flex-col items-center text-center">
|
|
<div className="grid h-16 w-16 place-items-center rounded-[5px] border border-sky-400/20 bg-sky-400/10">
|
|
<Monitor className="h-8 w-8 text-[#4FD1C5]" />
|
|
</div>
|
|
|
|
<h3 className="mt-5 text-xl font-black tracking-[-0.04em] text-white">
|
|
{vnc.state === "DISCONNECTED" ? "Sessão terminada" : "Consola indisponível"}
|
|
</h3>
|
|
<p className="mt-2 text-sm leading-6 text-slate-400">
|
|
{vnc.state === "DISCONNECTED"
|
|
? "Inicie uma nova sessão para voltar a aceder ao controlador."
|
|
: "Inicie uma sessão VNC para visualizar e controlar o controlador."}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
onPointerDown={hasFrame ? vnc.handleCanvasPointerDown : undefined}
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
className={
|
|
hasFrame
|
|
? "flex h-full w-full cursor-crosshair items-center justify-center overflow-hidden bg-black"
|
|
: "pointer-events-none absolute h-px w-px overflow-hidden opacity-0"
|
|
}
|
|
>
|
|
<canvas
|
|
ref={vnc.canvasRef}
|
|
className="pointer-events-none block max-h-full max-w-full bg-black object-contain [image-rendering:pixelated]"
|
|
style={{
|
|
aspectRatio: frameAspectRatio,
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function panelClass(isDark: boolean) {
|
|
return isDark
|
|
? `${RADIUS} flex h-full min-h-0 flex-col border border-white/10 bg-[#071421] p-3 shadow-[0_14px_34px_rgba(0,0,0,0.22)]`
|
|
: `${RADIUS} flex h-full min-h-0 flex-col border border-slate-200 bg-white p-3 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`;
|
|
}
|
|
|
|
function panelTitleClass(isDark: boolean) {
|
|
return isDark
|
|
? "text-base font-black text-slate-100"
|
|
: "text-base font-black text-slate-950";
|
|
}
|
|
|
|
function getConnectionLabel(state: VncConnectionState) {
|
|
switch (state) {
|
|
case "CONNECTING_WS":
|
|
return "A ligar WS";
|
|
case "CONNECTING_VNC":
|
|
return "A ligar VNC";
|
|
case "CONNECTED":
|
|
return "Ligado";
|
|
case "FIRST_FRAME":
|
|
return "Ativo";
|
|
case "DISCONNECTED":
|
|
return "Desconectado";
|
|
case "ERROR":
|
|
return "Erro";
|
|
case "IDLE":
|
|
default:
|
|
return "A aguardar...";
|
|
}
|
|
}
|
|
|
|
function Field({
|
|
theme,
|
|
label,
|
|
value,
|
|
onChange,
|
|
type = "text",
|
|
disabled,
|
|
rightElement,
|
|
}: {
|
|
theme: "dark" | "light";
|
|
label: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
type?: string;
|
|
disabled?: boolean;
|
|
rightElement?: ReactNode;
|
|
}) {
|
|
const isDark = theme === "dark";
|
|
|
|
return (
|
|
<label className="block">
|
|
<span
|
|
className={
|
|
isDark
|
|
? "text-[11px] font-black uppercase tracking-[0.20em] text-[#7F8CA3]"
|
|
: "text-[11px] font-black uppercase tracking-[0.20em] text-slate-500"
|
|
}
|
|
>
|
|
{label}
|
|
</span>
|
|
|
|
<div className="relative mt-2">
|
|
<input
|
|
type={type}
|
|
value={value}
|
|
disabled={disabled}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
className={
|
|
isDark
|
|
? `${RADIUS} h-10 w-full border border-[#263247] bg-[#07101B] px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-white outline-none transition placeholder:text-[#526074] focus:border-[#4FD1C5] disabled:cursor-not-allowed disabled:opacity-60`
|
|
: `${RADIUS} h-10 w-full border border-slate-200 bg-white px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-[#0F172A] outline-none transition placeholder:text-slate-400 focus:border-[#0F766E] disabled:cursor-not-allowed disabled:opacity-60`
|
|
}
|
|
/>
|
|
|
|
{rightElement && (
|
|
<div className="absolute right-1 top-1/2 -translate-y-1/2">
|
|
{rightElement}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function StatusCard({
|
|
theme,
|
|
icon,
|
|
title,
|
|
value,
|
|
color,
|
|
}: {
|
|
theme: "dark" | "light";
|
|
icon: ReactNode;
|
|
title: string;
|
|
value: string;
|
|
color: "green" | "blue" | "purple";
|
|
}) {
|
|
const isDark = theme === "dark";
|
|
|
|
const colors = {
|
|
green: isDark
|
|
? "bg-[#13202F] text-[#4FD1C5]"
|
|
: "bg-[#ECFDF5] text-[#0F766E]",
|
|
|
|
blue: isDark
|
|
? "bg-[#13202F] text-[#7DD3FC]"
|
|
: "bg-[#EFF6FF] text-[#0369A1]",
|
|
|
|
purple: isDark
|
|
? "bg-[#171B2B] text-[#A5B4FC]"
|
|
: "bg-[#EEF2FF] text-[#4F46E5]",
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
isDark
|
|
? `${RADIUS} min-w-0 border border-[#263247] bg-[#111A2B] p-3`
|
|
: `${RADIUS} min-w-0 border border-[#D7DEE8] bg-[#F8FAFC] p-3`
|
|
}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<div
|
|
className={`grid h-10 w-10 shrink-0 place-items-center ${RADIUS} ${colors[color]}`}
|
|
>
|
|
{icon}
|
|
</div>
|
|
|
|
<div className="min-w-0">
|
|
<p
|
|
className={
|
|
isDark
|
|
? "truncate text-[10px] font-bold uppercase tracking-[0.18em] text-[#7F8CA3]"
|
|
: "truncate text-[10px] font-bold uppercase tracking-[0.18em] text-slate-500"
|
|
}
|
|
>
|
|
{title}
|
|
</p>
|
|
|
|
<h3
|
|
className={
|
|
isDark
|
|
? "mt-1 truncate text-sm font-black text-white"
|
|
: "mt-1 truncate text-sm font-black text-[#0F172A]"
|
|
}
|
|
>
|
|
{value}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SmallInfo({
|
|
theme,
|
|
icon,
|
|
label,
|
|
value,
|
|
}: {
|
|
theme: "dark" | "light";
|
|
icon: ReactNode;
|
|
label: string;
|
|
value: string;
|
|
}) {
|
|
const isDark = theme === "dark";
|
|
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<div
|
|
className={
|
|
isDark
|
|
? `${RADIUS} grid h-9 w-9 shrink-0 place-items-center bg-[#13202F] text-[#4FD1C5]`
|
|
: `${RADIUS} grid h-9 w-9 shrink-0 place-items-center bg-[#ECFDF5] text-[#0F766E]`
|
|
}
|
|
>
|
|
{icon}
|
|
</div>
|
|
|
|
<div className="min-w-0">
|
|
<p
|
|
className={
|
|
isDark
|
|
? "truncate text-[10px] font-black uppercase tracking-[0.18em] text-[#7F8CA3]"
|
|
: "truncate text-[10px] font-black uppercase tracking-[0.18em] text-slate-500"
|
|
}
|
|
>
|
|
{label}
|
|
</p>
|
|
<p
|
|
className={
|
|
isDark
|
|
? "mt-1 truncate text-xs font-black text-white"
|
|
: "mt-1 truncate text-xs font-black text-[#0F172A]"
|
|
}
|
|
>
|
|
{value}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ConsolePage; |