Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/src/assets/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ body {
padding: 0;
margin: 0;
background: #000;
overflow: hidden;
}

::-webkit-scrollbar {
Expand Down
9 changes: 4 additions & 5 deletions web/src/hooks/useMenuBounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,10 @@ export function useMenuBounds(
isMenuExpanded: boolean
): MenuBounds {
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
const [menuBounds, setMenuBounds] = useState<MenuBounds>({
left: 0,
right: 0,
top: 0,
bottom: 0
const [menuBounds, setMenuBounds] = useState<MenuBounds>(() => {
const hw = window.innerWidth / 2;
const hh = window.innerHeight;
return { left: -hw, right: hw, top: -100, bottom: hh };
});

const handleResize = useCallback(() => {
Expand Down
113 changes: 113 additions & 0 deletions web/src/hooks/useMenuSnap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { RefObject, useCallback, useEffect, useRef, useState } from 'react';

import {
getMenuSnapEdge,
getMenuSnapOffset,
setMenuSnapEdge,
setMenuSnapOffset,
type SnapEdge
} from '@/lib/localstorage.ts';

/** Distance from each edge (px) that triggers snap */
const SNAP_THRESHOLD = 80;
/** How many px of the panel remain visible when snapped (the "indicator strip") */
export const SNAP_PEEK = 6;
/** Hover zone width (px) on the indicator strip to trigger pop-out */
export const SNAP_HOVER_ZONE = 28;

export interface SnapState {
edge: SnapEdge;
/** normalised position along the perpendicular axis [0,1] */
offset: number;
}

export interface UseMenuSnapReturn {
snapState: SnapState;
isSnapHovered: boolean;
/** Call at drag-stop with node rect + final absolute position */
onDragStop: (nodeRef: RefObject<HTMLElement | null>) => void;
/** Update hover state when mouse enters/leaves indicator */
setSnapHovered: (v: boolean) => void;
/** Un-snap (release from edge) */
clearSnap: () => void;
}

export function useMenuSnap(): UseMenuSnapReturn {
const [snapState, setSnapState] = useState<SnapState>(() => ({
edge: getMenuSnapEdge(),
offset: getMenuSnapOffset()
}));
const [isSnapHovered, setIsSnapHoveredRaw] = useState(false);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Persist whenever snap changes
useEffect(() => {
setMenuSnapEdge(snapState.edge);
setMenuSnapOffset(snapState.offset);
}, [snapState]);

const onDragStop = useCallback((nodeRef: RefObject<HTMLElement | null>) => {
const el = nodeRef.current;
if (!el) return;

const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;

// Distances from each edge
const dTop = rect.top;
const dBottom = vh - rect.bottom;
const dLeft = rect.left;
const dRight = vw - rect.right;

const min = Math.min(dTop, dBottom, dLeft, dRight);

if (min > SNAP_THRESHOLD) {
// Not close enough to any edge — clear snap
setSnapState({ edge: null, offset: 0.5 });
return;
}

let edge: SnapEdge = null;
let offset = 0.5;

if (min === dTop) {
edge = 'top';
offset = (rect.left + rect.width / 2) / vw;
} else if (min === dBottom) {
edge = 'bottom';
offset = (rect.left + rect.width / 2) / vw;
} else if (min === dLeft) {
edge = 'left';
offset = (rect.top + rect.height / 2) / vh;
} else {
edge = 'right';
offset = (rect.top + rect.height / 2) / vh;
}

offset = Math.max(0.05, Math.min(0.95, offset));
setSnapState({ edge, offset });
}, []);

const setSnapHovered = useCallback((v: boolean) => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
if (v) {
setIsSnapHoveredRaw(true);
} else {
// Small delay before hiding so cursor can travel to the expanded panel
hoverTimerRef.current = setTimeout(() => {
setIsSnapHoveredRaw(false);
}, 300);
}
}, []);

const clearSnap = useCallback(() => {
setSnapState({ edge: null, offset: 0.5 });
setIsSnapHoveredRaw(false);
}, []);

return { snapState, isSnapHovered, onDragStop, setSnapHovered, clearSnap };
}
63 changes: 63 additions & 0 deletions web/src/hooks/useScreenFitScale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';

import { resolutionAtom } from '@/jotai/screen.ts';

/**
* Computes a fit-scale so that the remote screen (native resolution) is
* always contained within the given container element without overflow.
*
* Returns 1 when the native resolution is unknown or smaller than the container.
* The caller should apply: transform: scale(fitScale * userVideoScale)
*
* Mouse coordinate mapping in absolute.tsx remains correct because:
* - The element keeps its CSS size at native resolution (nativeW × nativeH)
* - transform: scale(S) makes getBoundingClientRect() return nativeW*S × nativeH*S
* - getMediaSize() returns the real native size (videoWidth/naturalWidth/canvas.width)
* - mediaRatio === elementRatio after uniform scale → getCorrectedCoords maps correctly
*/
export function useScreenFitScale(containerRef: React.RefObject<HTMLElement | null>): number {
const resolution = useAtomValue(resolutionAtom);
const [fitScale, setFitScale] = useState(1);
// Keep a stable ref to avoid re-creating the ResizeObserver on every render
const resolutionRef = useRef(resolution);
resolutionRef.current = resolution;

useEffect(() => {
const el = containerRef.current;
if (!el) return;

function compute() {
const res = resolutionRef.current;
if (!res?.width || !res?.height) {
setFitScale(1);
return;
}
const cw = el!.clientWidth;
const ch = el!.clientHeight;
if (cw === 0 || ch === 0) return;
setFitScale(Math.min(cw / res.width, ch / res.height));
}

compute();

const ro = new ResizeObserver(compute);
ro.observe(el);
return () => ro.disconnect();
}, [containerRef]);

// Also recompute when resolution changes
useEffect(() => {
const el = containerRef.current;
if (!el || !resolution?.width || !resolution?.height) {
setFitScale(1);
return;
}
const cw = el.clientWidth;
const ch = el.clientHeight;
if (cw === 0 || ch === 0) return;
setFitScale(Math.min(cw / resolution.width, ch / resolution.height));
}, [containerRef, resolution]);

return fitScale;
}
2 changes: 2 additions & 0 deletions web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const en = {
ok: 'Ok',
cancel: 'Cancel',
loginButtonText: 'Login',
rememberPassword: 'Remember Password',
autoLogin: 'Auto Login',
tips: {
reset1:
'To reset the passwords, press and hold the BOOT button on the NanoKVM for 10 seconds.',
Expand Down
2 changes: 2 additions & 0 deletions web/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const zh = {
ok: '确定',
cancel: '取消',
loginButtonText: '登录',
rememberPassword: '记住密码',
autoLogin: '自动登录',
tips: {
reset1: '长按 NanoKVM 上的 BOOT 按键 10 秒钟来重置帐号。',
reset2: '详细操作步骤可参考此文档:',
Expand Down
29 changes: 29 additions & 0 deletions web/src/lib/localstorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language';
const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password';
const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items';
const MENU_AUTO_HIDE_KEY = 'nano-kvm-menu-auto-hide';
const MENU_SNAP_EDGE_KEY = 'nano-kvm-menu-snap-edge';
const MENU_SNAP_OFFSET_KEY = 'nano-kvm-menu-snap-offset';
const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm';

type ItemWithExpiry = {
Expand Down Expand Up @@ -230,3 +232,30 @@ export function getPowerConfirm() {
export function setPowerConfirm(enabled: boolean) {
localStorage.setItem(POWER_CONFIRM_KEY, String(enabled));
}

export type SnapEdge = 'top' | 'bottom' | 'left' | 'right' | null;

export function getMenuSnapEdge(): SnapEdge {
const v = localStorage.getItem(MENU_SNAP_EDGE_KEY);
if (v === 'top' || v === 'bottom' || v === 'left' || v === 'right') return v;
return null;
}

export function setMenuSnapEdge(edge: SnapEdge) {
if (edge === null) {
localStorage.removeItem(MENU_SNAP_EDGE_KEY);
} else {
localStorage.setItem(MENU_SNAP_EDGE_KEY, edge);
}
}

// Perpendicular axis normalized position [0,1] when snapped (e.g. left/right edge => vertical %)
export function getMenuSnapOffset(): number {
const v = localStorage.getItem(MENU_SNAP_OFFSET_KEY);
const n = parseFloat(v ?? '');
return isNaN(n) ? 0.5 : Math.max(0, Math.min(1, n));
}

export function setMenuSnapOffset(offset: number) {
localStorage.setItem(MENU_SNAP_OFFSET_KEY, String(offset));
}
70 changes: 66 additions & 4 deletions web/src/pages/auth/login/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ReactElement, useEffect, useState } from 'react';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { Button, Form, Input } from 'antd';
import { Button, Checkbox, Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';

Expand All @@ -11,16 +11,46 @@ import { Head } from '@/components/head.tsx';

import { Tips } from './tips.tsx';

const REMEMBER_KEY = 'nano-kvm-remember';

interface SavedCredentials {
username: string;
password: string;
autoLogin: boolean;
}

export const Login = (): ReactElement => {
const navigate = useNavigate();
const { t } = useTranslation();
const [form] = Form.useForm();

const [isLoading, setIsloading] = useState(false);
const [msg, setMsg] = useState('');
const [rememberChecked, setRememberChecked] = useState(false);

useEffect(() => {
if (existToken()) {
navigate('/', { replace: true });
return;
}

try {
const saved = localStorage.getItem(REMEMBER_KEY);
if (saved) {
const creds: SavedCredentials = JSON.parse(saved);
form.setFieldsValue({
username: creds.username,
password: creds.password,
remember: true,
autoLogin: creds.autoLogin
});
setRememberChecked(true);
if (creds.autoLogin) {
doLogin({ username: creds.username, password: creds.password, remember: true, autoLogin: true });
}
}
} catch {
localStorage.removeItem(REMEMBER_KEY);
}
}, []);

Expand All @@ -30,7 +60,7 @@ export const Login = (): ReactElement => {
}
}, [msg]);

function login(values: any) {
function doLogin(values: any) {
if (isLoading) return;
setIsloading(true);

Expand All @@ -50,6 +80,19 @@ export const Login = (): ReactElement => {
return;
}

if (values.remember) {
localStorage.setItem(
REMEMBER_KEY,
JSON.stringify({
username: values.username,
password: values.password,
autoLogin: values.autoLogin || false
} as SavedCredentials)
);
} else {
localStorage.removeItem(REMEMBER_KEY);
}

setMsg('');
setToken(rsp.data.token);

Expand All @@ -70,9 +113,10 @@ export const Login = (): ReactElement => {

<div className="flex h-screen w-screen flex-col items-center justify-center">
<Form
form={form}
style={{ minWidth: 300, maxWidth: 500 }}
initialValues={{ remember: true }}
onFinish={login}
initialValues={{ remember: false, autoLogin: false }}
onFinish={doLogin}
>
<div className="flex flex-col items-center justify-center pb-4">
<img
Expand Down Expand Up @@ -106,6 +150,24 @@ export const Login = (): ReactElement => {
/>
</Form.Item>

<div className="flex items-center justify-between pb-3">
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox
onChange={(e) => {
setRememberChecked(e.target.checked);
if (!e.target.checked) {
form.setFieldValue('autoLogin', false);
}
}}
>
{t('auth.rememberPassword')}
</Checkbox>
</Form.Item>
<Form.Item name="autoLogin" valuePropName="checked" noStyle>
<Checkbox disabled={!rememberChecked}>{t('auth.autoLogin')}</Checkbox>
</Form.Item>
</div>

<div className="pb-1 text-red-500">{msg}</div>

<Form.Item>
Expand Down
Loading