Compare commits
2 Commits
243a134a9c
...
6c2267c639
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c2267c639 | |||
| f3b97276c5 |
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { SettingsInput } from './ui/settings-input';
|
||||||
import { X, ChevronLeft, ChevronRight, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, Play, Pause, SkipForward, SkipBack, RotateCcw } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, Play, Pause, SkipForward, SkipBack, RotateCcw } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
@ -341,14 +342,16 @@ export default function DeviceDetailOverlay({
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Device Name</label>
|
<label className="text-sm text-neutral-500 block mb-2">Device Name</label>
|
||||||
<input
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
value={deviceData.name || device.name || `Device ${device.number}`}
|
value={deviceData.name || device.name || `Device ${device.number}`}
|
||||||
onChange={(e) => {
|
onCommit={(v) => {
|
||||||
const path = getDevicePath();
|
const path = getDevicePath();
|
||||||
updateDeviceSetting([...path, 'name'], e.target.value);
|
updateDeviceSetting([...path, 'name'], v);
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')}
|
||||||
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
|
containerClassName="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -356,14 +359,16 @@ export default function DeviceDetailOverlay({
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Base URL</label>
|
<label className="text-sm text-neutral-500 block mb-2">Base URL</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
value={deviceData.base_url ?? ''}
|
value={deviceData.base_url ?? ''}
|
||||||
onChange={(e) => {
|
onCommit={(v) => {
|
||||||
const path = getDevicePath();
|
const path = getDevicePath();
|
||||||
updateDeviceSetting([...path, 'base_url'], e.target.value);
|
updateDeviceSetting([...path, 'base_url'], v);
|
||||||
}}
|
}}
|
||||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')}
|
||||||
|
containerClassName="flex-1"
|
||||||
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setBrowsingField('base_url')}
|
onClick={() => setBrowsingField('base_url')}
|
||||||
|
|
@ -372,27 +377,16 @@ export default function DeviceDetailOverlay({
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const path = getDevicePath();
|
|
||||||
updateDeviceSetting([...path, 'base_url'], '');
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-red-50 hover:border-red-300 hover:text-red-500"
|
|
||||||
title="Clear Base URL"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">URL</label>
|
<label className="text-sm text-neutral-500 block mb-2">URL</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
value={deviceData.url ?? ''}
|
value={deviceData.url ?? ''}
|
||||||
onChange={(e) => {
|
onCommit={(newUrl) => {
|
||||||
const newUrl = e.target.value;
|
|
||||||
const devicePath = getDevicePath();
|
const devicePath = getDevicePath();
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
let dev = newConfig;
|
let dev = newConfig;
|
||||||
|
|
@ -401,17 +395,7 @@ export default function DeviceDetailOverlay({
|
||||||
dev.url = newUrl;
|
dev.url = newUrl;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
}}
|
}}
|
||||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
|
onClear={() => {
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => setBrowsingField('url')}
|
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
|
||||||
title="Browse"
|
|
||||||
>
|
|
||||||
<FolderOpen className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const devicePath = getDevicePath();
|
const devicePath = getDevicePath();
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
let dev = newConfig;
|
let dev = newConfig;
|
||||||
|
|
@ -420,10 +404,15 @@ export default function DeviceDetailOverlay({
|
||||||
delete dev.media_set;
|
delete dev.media_set;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-red-50 hover:border-red-300 hover:text-red-500"
|
containerClassName="flex-1"
|
||||||
title="Clear URL and media set"
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setBrowsingField('url')}
|
||||||
|
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
||||||
|
title="Browse"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -440,14 +429,16 @@ export default function DeviceDetailOverlay({
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Cache</label>
|
<label className="text-sm text-neutral-500 block mb-2">Cache</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
value={deviceData.cache ?? ''}
|
value={deviceData.cache ?? ''}
|
||||||
onChange={(e) => {
|
onCommit={(v) => {
|
||||||
const path = getDevicePath();
|
const path = getDevicePath();
|
||||||
updateDeviceSetting([...path, 'cache'], e.target.value);
|
updateDeviceSetting([...path, 'cache'], v);
|
||||||
}}
|
}}
|
||||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')}
|
||||||
|
containerClassName="flex-1"
|
||||||
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setBrowsingField('cache')}
|
onClick={() => setBrowsingField('cache')}
|
||||||
|
|
@ -456,16 +447,6 @@ export default function DeviceDetailOverlay({
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const path = getDevicePath();
|
|
||||||
updateDeviceSetting([...path, 'cache'], '');
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-red-50 hover:border-red-300 hover:text-red-500"
|
|
||||||
title="Clear Cache"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -500,12 +481,12 @@ export default function DeviceDetailOverlay({
|
||||||
{deviceData.baud !== undefined && (
|
{deviceData.baud !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Baud Rate</label>
|
<label className="text-sm text-neutral-500 block mb-2">Baud Rate</label>
|
||||||
<input
|
<SettingsInput
|
||||||
type="number"
|
type="number"
|
||||||
value={deviceData.baud}
|
value={String(deviceData.baud ?? '')}
|
||||||
onChange={(e) => {
|
onCommit={(v) => {
|
||||||
const path = getDevicePath();
|
const path = getDevicePath();
|
||||||
updateDeviceSetting([...path, 'baud'], parseInt(e.target.value));
|
updateDeviceSetting([...path, 'baud'], parseInt(v));
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Cable, Code2, Cpu, FolderOpen, Link, List, Zap } from 'lucide-react';
|
import { Cable, Code2, Cpu, FolderOpen, Link, List, Zap } from 'lucide-react';
|
||||||
import MediaBrowser from './MediaBrowser';
|
import MediaBrowser from './MediaBrowser';
|
||||||
|
import { SettingsInput } from './ui/settings-input';
|
||||||
|
|
||||||
interface IECPageProps {
|
interface IECPageProps {
|
||||||
config: any;
|
config: any;
|
||||||
|
|
@ -92,11 +93,13 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Autoboot</label>
|
<label className="text-sm text-neutral-500 block mb-2">Autoboot</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
value={settings.autoboot || ''}
|
value={settings.autoboot || ''}
|
||||||
onChange={(e) => updateSetting(['settings', 'autoboot'], e.target.value)}
|
onCommit={(v) => updateSetting(['settings', 'autoboot'], v)}
|
||||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
|
onClear={() => updateSetting(['settings', 'autoboot'], '')}
|
||||||
|
containerClassName="flex-1"
|
||||||
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMediaBrowser(true)}
|
onClick={() => setShowMediaBrowser(true)}
|
||||||
|
|
@ -152,11 +155,13 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
|
||||||
{key === 'default' ? 'Default' : key.toUpperCase()}
|
{key === 'default' ? 'Default' : key.toUpperCase()}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
value={settings.drive_roms?.[key] || ''}
|
value={settings.drive_roms?.[key] || ''}
|
||||||
onChange={(e) => updateSetting(['settings', 'drive_roms', key], e.target.value)}
|
onCommit={(v) => updateSetting(['settings', 'drive_roms', key], v)}
|
||||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
|
onClear={() => updateSetting(['settings', 'drive_roms', key], '')}
|
||||||
|
containerClassName="flex-1"
|
||||||
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDriveRomBrowsingKey(key)}
|
onClick={() => setDriveRomBrowsingKey(key)}
|
||||||
|
|
@ -211,11 +216,11 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
|
||||||
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${value ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${value ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<SettingsInput
|
||||||
type="number"
|
type="number"
|
||||||
value={value}
|
value={String(value)}
|
||||||
disabled={isCompatMode}
|
disabled={isCompatMode}
|
||||||
onChange={(e) => updateSetting(['settings', 'directory', key], parseInt(e.target.value))}
|
onCommit={(v) => updateSetting(['settings', 'directory', key], parseInt(v))}
|
||||||
className="w-24 px-3 py-1 border border-neutral-300 rounded-lg text-right disabled:cursor-not-allowed"
|
className="w-24 px-3 py-1 border border-neutral-300 rounded-lg text-right disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
57
src/app/components/ui/settings-input.tsx
Normal file
57
src/app/components/ui/settings-input.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SettingsInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'onBlur' | 'value'> {
|
||||||
|
value: string | number;
|
||||||
|
onCommit: (value: string) => void;
|
||||||
|
/** When provided, an inline × button appears and calls this on click. */
|
||||||
|
onClear?: () => void;
|
||||||
|
/** className for the outer wrapper div (e.g. "flex-1"). Only used when onClear is set. */
|
||||||
|
containerClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A controlled input that buffers keystrokes locally and only calls
|
||||||
|
* `onCommit` when the field loses focus, preventing a settings save
|
||||||
|
* on every keystroke.
|
||||||
|
*
|
||||||
|
* When `onClear` is provided the component wraps itself in a relative div
|
||||||
|
* and shows an inline × button. Move any flex-sizing classes (e.g. flex-1)
|
||||||
|
* to `containerClassName` so they apply to the wrapper instead of the input.
|
||||||
|
*/
|
||||||
|
export function SettingsInput({ value, onCommit, onClear, containerClassName, className, ...props }: SettingsInputProps) {
|
||||||
|
const [local, setLocal] = useState(String(value ?? ''));
|
||||||
|
|
||||||
|
// Sync if the committed value changes externally (e.g. device switch).
|
||||||
|
useEffect(() => { setLocal(String(value ?? '')); }, [value]);
|
||||||
|
|
||||||
|
const input = (
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
className={`${onClear ? 'w-full pr-7' : ''} ${className ?? ''}`}
|
||||||
|
value={local}
|
||||||
|
onChange={e => setLocal(e.target.value)}
|
||||||
|
onBlur={() => onCommit(local)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onClear) return input;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${containerClassName ?? ''}`}>
|
||||||
|
{input}
|
||||||
|
{local !== '' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
// mousedown fires before blur; preventDefault keeps focus so onBlur doesn't commit first
|
||||||
|
onMouseDown={e => { e.preventDefault(); setLocal(''); onClear(); }}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Clear"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user