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
33 changes: 18 additions & 15 deletions src/config.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import serverConfig from './server-config.json';
import serverConfig from "./server-config.json";

/**
* Server config (server-config.json): only host, frontendPort, address are writable via Save Config.
* Client settings (mouse sensitivity, invert scroll, theme) are stored in localStorage only.
*/
export const APP_CONFIG = {
SITE_NAME: "Rein",
SITE_DESCRIPTION: "Remote controller for your PC",
REPO_URL: "https://github.com/imxade/rein",
THEME_STORAGE_KEY: "rein-theme",
}
SITE_NAME: "Rein",
SITE_DESCRIPTION: "Remote controller for your PC",
REPO_URL: "https://github.com/imxade/rein",
THEME_STORAGE_KEY: "rein-theme",
};

export const THEMES = {
LIGHT: 'cupcake',
DARK: 'dracula',
DEFAULT: 'dracula',
}
LIGHT: "cupcake",
DARK: "dracula",
DEFAULT: "dracula",
};

/** Config from server-config.json. Only host, frontendPort, address are writable via Settings Save. */
export const CONFIG = {
// Port for the Vite Frontend
FRONTEND_PORT: serverConfig.frontendPort,
MOUSE_INVERT: serverConfig.mouseInvert ?? false,
// Default to 1.0 if not set
MOUSE_SENSITIVITY: serverConfig.mouseSensitivity ?? 1.0
FRONTEND_PORT: serverConfig.frontendPort ?? 3000,
MOUSE_INVERT: serverConfig.mouseInvert ?? false,
MOUSE_SENSITIVITY: serverConfig.mouseSensitivity ?? 1.0,
};
51 changes: 33 additions & 18 deletions src/hooks/useTrackpadGesture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,21 @@ const getTouchDistance = (a: TrackedTouch, b: TrackedTouch): number => {
return Math.sqrt(dx * dx + dy * dy);
};

const CLIENT_KEYS = { SENSITIVITY: 'rein_mouse_sensitivity', INVERT: 'rein_mouse_invert' } as const;

function getClientSettings(): { sensitivity: number; invert: boolean } {
if (typeof localStorage === 'undefined') return { sensitivity: 1.0, invert: false };
const s = localStorage.getItem(CLIENT_KEYS.SENSITIVITY);
const sensitivity = s !== null && !Number.isNaN(Number(s)) ? Number(s) : 1.0;
const invert = localStorage.getItem(CLIENT_KEYS.INVERT) === 'true';
return { sensitivity, invert };
}
Comment on lines +19 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

getClientSettings helper is clean and SSR-safe — nice addition.

The typeof localStorage guard and the Number.isNaN validation are well done. One minor note: Number('') is 0 and Number.isNaN(0) is false, so an empty string in localStorage would parse as sensitivity 0 (effectively disabling movement). If that's undesirable, consider adding Number(s) > 0 or s !== '' to the guard.

🤖 Prompt for AI Agents
In `@src/hooks/useTrackpadGesture.ts` around lines 19 - 27, getClientSettings
currently treats an empty string in localStorage as a valid sensitivity
(Number('') === 0), which can unintentionally disable movement; update the
validation in getClientSettings (and the CLIENT_KEYS.SENSITIVITY usage) to
reject empty or non-positive values — e.g., require s !== '' && Number(s) > 0
(or similar) before using Number(s), otherwise fall back to the default 1.0 so
sensitivity is never zero or negative.

Comment on lines +21 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

_defaultSensitivity parameter is accepted but never used; getClientSettings hardcodes its own default.

_defaultSensitivity (Line 32) is part of the public signature and callers may pass a custom value, but it is silently ignored — getClientSettings() always falls back to 1.0. Either plumb the default through:

-function getClientSettings(): { sensitivity: number; invert: boolean } {
-    if (typeof localStorage === 'undefined') return { sensitivity: 1.0, invert: false };
+function getClientSettings(defaultSensitivity: number = 1.0): { sensitivity: number; invert: boolean } {
+    if (typeof localStorage === 'undefined') return { sensitivity: defaultSensitivity, invert: false };
     const s = localStorage.getItem(CLIENT_KEYS.SENSITIVITY);
-    const sensitivity = s !== null && !Number.isNaN(Number(s)) ? Number(s) : 1.0;
+    const sensitivity = s !== null && !Number.isNaN(Number(s)) ? Number(s) : defaultSensitivity;

…and call it as getClientSettings(_defaultSensitivity) on Line 145, or remove the parameter from the hook signature to avoid misleading callers.

Also applies to: 32-33

🤖 Prompt for AI Agents
In `@src/hooks/useTrackpadGesture.ts` around lines 21 - 27, getClientSettings
currently ignores the hook's _defaultSensitivity parameter and always uses 1.0;
either remove the unused _defaultSensitivity from the hook signature or make
getClientSettings accept a defaultSensitivity argument and use it when
localStorage has no valid sensitivity. Concretely: modify getClientSettings to
accept defaultSensitivity (number) and replace the hardcoded 1.0 fallback with
that parameter, then update the call site that invokes getClientSettings to pass
_defaultSensitivity (the hook parameter); alternatively, remove
_defaultSensitivity from the useTrackpadGesture signature and all callers to
avoid a misleading unused parameter.


export const useTrackpadGesture = (
send: (msg: any) => void,
scrollMode: boolean,
sensitivity: number = 1.5,
axisThreshold: number = 2.5
_defaultSensitivity: number = 1.5,
axisThreshold: number = 2.5
) => {
const [isTracking, setIsTracking] = useState(false);

Expand Down Expand Up @@ -130,8 +140,10 @@ export const useTrackpadGesture = (
tracked.timeStamp = e.timeStamp;
}

// Send movement if we've moved and not in timeout period
// Send movement if we've moved and not in timeout period; use client settings so changes take effect immediately
if (moved.current && e.timeStamp - lastEndTimeStamp.current >= TOUCH_TIMEOUT) {
const { sensitivity, invert } = getClientSettings();
const scrollSign = invert ? 1 : -1;
if (!scrollMode && ongoingTouches.current.length === 2) {
const dist = getTouchDistance(ongoingTouches.current[0], ongoingTouches.current[1]);
const delta = lastPinchDist.current !== null ? dist - lastPinchDist.current : 0;
Expand All @@ -141,27 +153,30 @@ export const useTrackpadGesture = (
send({ type: 'zoom', delta: delta * sensitivity });
} else {
lastPinchDist.current = dist;
send({ type: 'scroll', dx: -sumX * sensitivity, dy: -sumY * sensitivity });
send({ type: 'scroll', dx: sumX * sensitivity * scrollSign, dy: sumY * sensitivity * scrollSign });
}
} else if (scrollMode) {
// Scroll mode: single finger scrolls, or two-finger scroll in cursor mode
let scrollDx = sumX;
let scrollDy = sumY;
// Scroll mode: single finger scrolls; dominant axis (from main) for cleaner scroll
const scrollDx = -sumX;
const scrollDy = -sumY;
const absDx = Math.abs(scrollDx);
const absDy = Math.abs(scrollDy);
if (scrollMode) {
if (absDx > absDy * axisThreshold) {
// Horizontal is dominant - ignore vertical
scrollDy = 0;
} else if (absDy > absDx * axisThreshold) {
// Vertical is dominant - ignore horizontal
scrollDx = 0;
}
}
send({ type: 'scroll', dx: Math.round(-scrollDx * sensitivity * 10) / 10 , dy: Math.round(-scrollDy * sensitivity * 10) / 10 });
const useHorizontal = absDx > axisThreshold * absDy;
const useVertical = absDy > axisThreshold * absDx;
const dx = useHorizontal && !useVertical ? scrollDx : 0;
const dy = useVertical && !useHorizontal ? scrollDy : 0;
send({
type: 'scroll',
dx: Math.round(dx * sensitivity * scrollSign * 10) / 10,
dy: Math.round(dy * sensitivity * scrollSign * 10) / 10,
});
Comment on lines 158 to 172
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

invert setting works backwards in scrollMode due to the extra negation.

Lines 160–161 negate sumX/sumY (scrollDx = -sumX), but then Lines 170–171 also apply scrollSign (which already encodes inversion). The double negation means that when invert is false (scrollSign = −1), scrollMode scrolls in the opposite direction compared to 2-finger scroll — and toggling invert flips them out of sync.

Remove the upfront negation so both scroll paths share the same direction convention:

🐛 Proposed fix
             } else if (scrollMode) {
-                // Scroll mode: single finger scrolls; dominant axis (from main) for cleaner scroll
-                const scrollDx = -sumX;
-                const scrollDy = -sumY;
+                // Scroll mode: single finger scrolls; dominant axis for cleaner scroll
+                const scrollDx = sumX;
+                const scrollDy = sumY;
                 const absDx = Math.abs(scrollDx);
                 const absDy = Math.abs(scrollDy);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if (scrollMode) {
// Scroll mode: single finger scrolls, or two-finger scroll in cursor mode
send({ type: 'scroll', dx: -sumX * sensitivity, dy: -sumY * sensitivity });
// Scroll mode: single finger scrolls; dominant axis (from main) for cleaner scroll
const scrollDx = -sumX;
const scrollDy = -sumY;
const absDx = Math.abs(scrollDx);
const absDy = Math.abs(scrollDy);
const useHorizontal = absDx > axisThreshold * absDy;
const useVertical = absDy > axisThreshold * absDx;
const dx = useHorizontal && !useVertical ? scrollDx : 0;
const dy = useVertical && !useHorizontal ? scrollDy : 0;
send({
type: 'scroll',
dx: Math.round(dx * sensitivity * scrollSign * 10) / 10,
dy: Math.round(dy * sensitivity * scrollSign * 10) / 10,
});
} else if (scrollMode) {
// Scroll mode: single finger scrolls; dominant axis for cleaner scroll
const scrollDx = sumX;
const scrollDy = sumY;
const absDx = Math.abs(scrollDx);
const absDy = Math.abs(scrollDy);
const useHorizontal = absDx > axisThreshold * absDy;
const useVertical = absDy > axisThreshold * absDx;
const dx = useHorizontal && !useVertical ? scrollDx : 0;
const dy = useVertical && !useHorizontal ? scrollDy : 0;
send({
type: 'scroll',
dx: Math.round(dx * sensitivity * scrollSign * 10) / 10,
dy: Math.round(dy * sensitivity * scrollSign * 10) / 10,
});
🤖 Prompt for AI Agents
In `@src/hooks/useTrackpadGesture.ts` around lines 158 - 172, In
useTrackpadGesture's scrollMode branch the code negates sumX/sumY into
scrollDx/scrollDy and then multiplies by scrollSign, causing double negation and
inverted behavior; to fix, stop negating at assignment (set scrollDx = sumX and
scrollDy = sumY or otherwise remove the leading '-'), keep the later
multiplication by scrollSign/sensitivity when building the 'scroll' message, and
ensure variables scrollMode, sumX, sumY, scrollDx, scrollDy and scrollSign are
used as described so single- and two-finger scrolls share the same direction
convention.

} else if (ongoingTouches.current.length === 1 || dragging.current) {
// Cursor movement (only in cursor mode with 1 finger, or when dragging)
send({ type: 'move', dx: Math.round(sumX * sensitivity * 10) / 10 , dy: Math.round(sumY * sensitivity * 10) / 10 });
send({
type: 'move',
dx: Math.round(sumX * sensitivity * 10) / 10,
dy: Math.round(sumY * sensitivity * 10) / 10,
});
}
}
};
Expand Down
12 changes: 12 additions & 0 deletions src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Outlet, createRootRoute, Link, Scripts, HeadContent } from '@tanstack/react-router'
// import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import * as React from 'react'
import { useEffect } from 'react'
import '../styles.css'
import { APP_CONFIG, THEMES } from '../config'

export const Route = createRootRoute({
component: RootComponent,
Expand All @@ -24,6 +26,15 @@ function RootComponent() {
)
}

function ThemeInit() {
useEffect(() => {
const saved = typeof localStorage !== 'undefined' && localStorage.getItem(APP_CONFIG.THEME_STORAGE_KEY)
const theme = saved === THEMES.LIGHT || saved === THEMES.DARK ? saved : THEMES.DEFAULT
document.documentElement.setAttribute('data-theme', theme)
}, [])
return null
}

function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html>
Expand All @@ -35,6 +46,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<link rel="manifest" href="/manifest.json" />
</head>
<body className="bg-neutral-900 text-white overflow-hidden overscroll-none">
<ThemeInit />
<div className="flex flex-col h-[100dvh]">
<Navbar />
<main className="flex-1 overflow-hidden relative">
Expand Down
157 changes: 114 additions & 43 deletions src/routes/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,91 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import QRCode from 'qrcode';
import { CONFIG } from '../config';
import { CONFIG, APP_CONFIG, THEMES } from '../config';

export const Route = createFileRoute('/settings')({
component: SettingsPage,
})

// Client-only settings: stored in localStorage, never sent to server-config.json
const CLIENT_KEYS = {
SENSITIVITY: 'rein_mouse_sensitivity',
INVERT: 'rein_mouse_invert',
THEME: APP_CONFIG.THEME_STORAGE_KEY,
} as const
const DEFAULT_SENSITIVITY = 1.0
const DEFAULT_INVERT = false

function SettingsPage() {
const [ip, setIp] = useState('');
const [frontendPort, setFrontendPort] = useState(String(CONFIG.FRONTEND_PORT));
const [invertScroll, setInvertScroll] = useState(CONFIG.MOUSE_INVERT);
const [sensitivity, setSensitivity] = useState(CONFIG.MOUSE_SENSITIVITY);
const [qrData, setQrData] = useState('');
const [ip, setIp] = useState('')
const [frontendPort, setFrontendPort] = useState(String(CONFIG.FRONTEND_PORT))
const [invertScroll, setInvertScroll] = useState(DEFAULT_INVERT)
const [sensitivity, setSensitivity] = useState(DEFAULT_SENSITIVITY)
const [theme, setTheme] = useState(THEMES.DEFAULT)
const [qrData, setQrData] = useState('')
const hasLoadedFromStorage = useRef(false)
const isFirstSensitivity = useRef(true)
const isFirstInvert = useRef(true)
const isFirstTheme = useRef(true)

// Load initial state
// Load client settings from localStorage only on client, so they persist when navigating back
useEffect(() => {
const storedIp = localStorage.getItem('rein_ip');
const defaultIp = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
if (typeof window === 'undefined') return
const storedIp = localStorage.getItem('rein_ip')
const defaultIp = window.location.hostname || 'localhost'
setIp(storedIp || defaultIp)

setFrontendPort(String(CONFIG.FRONTEND_PORT))

const s = localStorage.getItem(CLIENT_KEYS.SENSITIVITY)
if (s !== null) {
const n = Number(s)
if (!Number.isNaN(n)) setSensitivity(n)
}
const inv = localStorage.getItem(CLIENT_KEYS.INVERT)
if (inv === 'true') setInvertScroll(true)
if (inv === 'false') setInvertScroll(false)

setIp(storedIp || defaultIp);
// We don't store frontend port in local storage for now, just load from config default
setFrontendPort(String(CONFIG.FRONTEND_PORT));
}, []);
const t = localStorage.getItem(CLIENT_KEYS.THEME)
if (t === THEMES.LIGHT || t === THEMES.DARK) {
setTheme(t)
document.documentElement.setAttribute('data-theme', t)
}

hasLoadedFromStorage.current = true
}, [])

// Effect: Update LocalStorage and Generate QR
// Persist client settings to localStorage after user changes (skip first run to avoid overwriting loaded values)
useEffect(() => {
if (typeof window === 'undefined' || !hasLoadedFromStorage.current) return
if (isFirstSensitivity.current) { isFirstSensitivity.current = false; return }
localStorage.setItem(CLIENT_KEYS.SENSITIVITY, String(sensitivity))
}, [sensitivity])
useEffect(() => {
if (typeof window === 'undefined' || !hasLoadedFromStorage.current) return
if (isFirstInvert.current) { isFirstInvert.current = false; return }
localStorage.setItem(CLIENT_KEYS.INVERT, String(invertScroll))
}, [invertScroll])
useEffect(() => {
if (!ip) return;
localStorage.setItem('rein_ip', ip);
if (typeof window === 'undefined' || !hasLoadedFromStorage.current) return
if (isFirstTheme.current) { isFirstTheme.current = false; return }
localStorage.setItem(CLIENT_KEYS.THEME, theme)
document.documentElement.setAttribute('data-theme', theme)
}, [theme])

// Update LocalStorage for IP and generate QR
useEffect(() => {
if (!ip) return
localStorage.setItem('rein_ip', ip)
if (typeof window !== 'undefined') {
// Point to Frontend
const appPort = String(CONFIG.FRONTEND_PORT);
const protocol = window.location.protocol;
const shareUrl = `${protocol}//${ip}:${appPort}/trackpad`;

const appPort = String(CONFIG.FRONTEND_PORT)
const protocol = window.location.protocol
const shareUrl = `${protocol}//${ip}:${appPort}/trackpad`
QRCode.toDataURL(shareUrl)
.then(setQrData)
.catch((e) => console.error('QR Error:', e));
.catch((e) => console.error('QR Error:', e))
}
}, [ip]);
}, [ip])

// Effect: Auto-detect LAN IP from Server (only if on localhost)
useEffect(() => {
Expand Down Expand Up @@ -146,6 +192,20 @@ function SettingsPage() {
</label>
</div>

<div className="form-control w-full">
<label className="label">
<span className="label-text">Theme</span>
</label>
<select
className="select select-bordered w-full"
value={theme}
onChange={(e) => setTheme(e.target.value as typeof THEMES.LIGHT | typeof THEMES.DARK)}
>
<option value={THEMES.DARK}>Dark (dracula)</option>
<option value={THEMES.LIGHT}>Light (cupcake)</option>
</select>
</div>
Comment on lines +195 to +207
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Accessibility: <label> is not associated with the <select> control.

Static analysis flagged this — the <label> on Line 196 has no htmlFor attribute and the <select> is not nested inside it. Screen readers won't associate the label text "Theme" with the dropdown.

Proposed fix
 <div className="form-control w-full">
-    <label className="label">
+    <label className="label" htmlFor="theme-select">
         <span className="label-text">Theme</span>
     </label>
     <select
+        id="theme-select"
         className="select select-bordered w-full"
         value={theme}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="form-control w-full">
<label className="label">
<span className="label-text">Theme</span>
</label>
<select
className="select select-bordered w-full"
value={theme}
onChange={(e) => setTheme(e.target.value as typeof THEMES.LIGHT | typeof THEMES.DARK)}
>
<option value={THEMES.DARK}>Dark (dracula)</option>
<option value={THEMES.LIGHT}>Light (cupcake)</option>
</select>
</div>
<div className="form-control w-full">
<label className="label" htmlFor="theme-select">
<span className="label-text">Theme</span>
</label>
<select
id="theme-select"
className="select select-bordered w-full"
value={theme}
onChange={(e) => setTheme(e.target.value as typeof THEMES.LIGHT | typeof THEMES.DARK)}
>
<option value={THEMES.DARK}>Dark (dracula)</option>
<option value={THEMES.LIGHT}>Light (cupcake)</option>
</select>
</div>
🧰 Tools
🪛 Biome (2.3.14)

[error] 196-198: A form label must be associated with an input.

Consider adding a for or htmlFor attribute to the label element or moving the input element to inside the label element.

(lint/a11y/noLabelWithoutControl)

🤖 Prompt for AI Agents
In `@src/routes/settings.tsx` around lines 195 - 207, The label "Theme" is not
programmatically associated with the select, so update the Theme field to
associate the label and control: add a unique id (e.g., "theme-select") to the
<select> and set the <label>'s htmlFor to that id (or alternatively nest the
<select> inside the <label>) so screen readers can link the label to the
control; ensure this change touches the select element that uses value={theme}
and onChange={(e) => setTheme(...)} and references THEMES.DARK/THEMES.LIGHT.


<div className="form-control w-full">
<label className="label">
<span className="label-text">Port</span>
Expand All @@ -171,30 +231,41 @@ function SettingsPage() {
<button
className="btn btn-neutral w-full"
onClick={() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = `${protocol}//${host}/ws`;
const socket = new WebSocket(wsUrl);
const portNum = parseInt(frontendPort, 10)
if (Number.isNaN(portNum) || portNum < 1 || portNum > 65535) {
alert('Please enter a valid port (1–65535).')
return
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}/ws`
const socket = new WebSocket(wsUrl)

socket.onerror = () => {
alert('Could not connect to server. Is the app running?')
}
socket.onopen = () => {
socket.send(JSON.stringify({
type: 'update-config',
config: {
frontendPort: parseInt(frontendPort),
mouseInvert: invertScroll,
mouseSensitivity: sensitivity,
config: { frontendPort: portNum },
}))
}
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'config-updated' && data.success) {
socket.close()
const newProtocol = window.location.protocol
const newHostname = window.location.hostname
const newUrl = `${newProtocol}//${newHostname}:${portNum}/settings`
setTimeout(() => { window.location.href = newUrl }, 800)
} else if (data.type === 'config-updated' && !data.success) {
alert('Failed to save config: ' + (data.error || 'Unknown error'))
}
}));

// Give server time to write config and restart
setTimeout(() => {
socket.close();
const newProtocol = window.location.protocol;
const newHostname = window.location.hostname;
const newUrl = `${newProtocol}//${newHostname}:${frontendPort}/settings`;
window.location.href = newUrl;
}, 1000);
};
} catch (_e) {
// ignore non-JSON messages
}
}
Comment on lines 231 to +268
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Save button has no guard against double-clicks or loading feedback.

If the user clicks "Save Config" multiple times, each click opens a new WebSocket and sends a config update. This could cause multiple redirects racing or confusing error messages. Consider disabling the button while the save is in progress and showing a loading indicator.

Additionally, the WebSocket created in the onClick handler is not cleaned up if the component unmounts mid-save (e.g., user navigates away). This is a minor leak since the page will redirect anyway, but worth noting.

Sketch: add a saving guard
+const [saving, setSaving] = useState(false)
 ...
 <button
-    className="btn btn-neutral w-full"
+    className="btn btn-neutral w-full"
+    disabled={saving}
     onClick={() => {
+        if (saving) return
+        setSaving(true)
         const portNum = parseInt(frontendPort, 10)
         if (Number.isNaN(portNum) || portNum < 1 || portNum > 65535) {
             alert('Please enter a valid port (1–65535).')
+            setSaving(false)
             return
         }
         ...
+        socket.onerror = () => {
+            alert('Could not connect to server. Is the app running?')
+            setSaving(false)
+        }
     }}
 >
-    Save Config
+    {saving ? 'Saving…' : 'Save Config'}
 </button>
🧰 Tools
🪛 Biome (2.3.14)

[error] 231-271: Provide an explicit type prop for the button element.

The default type of a button is submit, which causes the submission of a form when placed inside a form element. This is likely not the behaviour that you want inside a React application.
Allowed button types are: submit, button or reset

(lint/a11y/useButtonType)

🤖 Prompt for AI Agents
In `@src/routes/settings.tsx` around lines 231 - 268, Add a saving guard and
cleanup: introduce a component state (e.g., isSaving) and check it at the start
of the Save button's onClick (referencing frontendPort and the button's onClick
handler) to ignore additional clicks and disable the button and show a loading
indicator while saving; when initiating the WebSocket (new WebSocket(wsUrl) /
socket) set isSaving=true, and on success/failure (socket.onopen,
socket.onmessage error branch, socket.onerror) set isSaving=false on failure and
proceed with redirect on success. Also store the socket and any setTimeout id in
refs so you can close socket and clearTimeout in a useEffect cleanup (to handle
component unmount) and ensure socket.close() is called on all terminal paths to
avoid leaks and racing redirects.

}}
>
Save Config
Expand Down
16 changes: 15 additions & 1 deletion src/server/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,24 @@ export function createWsServer(server: Server) {
if (msg.type === 'update-config') {
console.log('Updating config:', msg.config);
try {
// Only server config: host, port, address. Client settings (sensitivity, invert, theme) stay in localStorage.
const SERVER_CONFIG_KEYS = ['host', 'frontendPort', 'address'] as const;
const configPath = './src/server-config.json';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const current = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf-8')) : {};
const newConfig = { ...current, ...msg.config };
const incoming = msg.config || {};
const filtered: Record<string, unknown> = {};
for (const key of SERVER_CONFIG_KEYS) {
if (!Object.prototype.hasOwnProperty.call(incoming, key)) continue;
const val = incoming[key];
if (key === 'frontendPort') {
const num = Number(val);
if (!Number.isNaN(num) && num >= 1 && num <= 65535) filtered[key] = num;
} else {
filtered[key] = val;
}
}
const newConfig = { ...current, ...filtered };

fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2));
ws.send(JSON.stringify({ type: 'config-updated', success: true }));
Expand Down