feat: add locking styles
This commit is contained in:
@@ -29,3 +29,9 @@
|
|||||||
.app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay {
|
.app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay {
|
||||||
left: var(--sidebar-width-expanded);
|
left: var(--sidebar-width-expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-root {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { DSP408 } from '../../../../types';
|
|||||||
import { SidebarId } from '../../constants';
|
import { SidebarId } from '../../constants';
|
||||||
import './DSP408Panel.css';
|
import './DSP408Panel.css';
|
||||||
import MiscellaneousPanel from '../MiscellaneousSection/MiscellaneousSection';
|
import MiscellaneousPanel from '../MiscellaneousSection/MiscellaneousSection';
|
||||||
|
import { PasswordDialog } from '../PasswordDialog/PasswordDialog';
|
||||||
|
|
||||||
function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
||||||
const [activeSidebar, setActiveSidebar] = useState<SidebarId>('overview');
|
const [activeSidebar, setActiveSidebar] = useState<SidebarId>('overview');
|
||||||
@@ -187,27 +188,36 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}>
|
<div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
items={sidebarItems}
|
items={sidebarItems}
|
||||||
activeId={activeSidebar}
|
activeId={activeSidebar}
|
||||||
onSelect={setActiveSidebar}
|
onSelect={setActiveSidebar}
|
||||||
appName={dsp.name}
|
appName={dsp.name}
|
||||||
appOptions={presets}
|
appOptions={presets}
|
||||||
selectedAppId={presets[selectedPreset]?.id}
|
selectedAppId={presets[selectedPreset]?.id}
|
||||||
onAppChange={onAppChange}
|
onAppChange={onAppChange}
|
||||||
onAppRename={api.setCurrentPresetName}
|
onAppRename={api.setCurrentPresetName}
|
||||||
onItemRename={onItemRename}
|
onItemRename={onItemRename}
|
||||||
expanded={sidebarExpanded}
|
expanded={sidebarExpanded}
|
||||||
onToggle={() => setSidebarExpanded((v) => !v)}
|
onToggle={() => setSidebarExpanded((v) => !v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LoadingOverlay
|
<LoadingOverlay
|
||||||
active={presetLoading}
|
active={presetLoading}
|
||||||
label="Recalling preset…"
|
label="Recalling preset…"
|
||||||
overlayClassName="dsp-loading-overlay"
|
overlayClassName="dsp-loading-overlay"
|
||||||
>
|
>
|
||||||
<div className="dsp-content">{renderTab()}</div>
|
<div className="dsp-content">{renderTab()}</div>
|
||||||
</LoadingOverlay>
|
</LoadingOverlay>
|
||||||
|
|
||||||
|
|
||||||
|
{dsp.passwordRequired && (
|
||||||
|
<PasswordDialog
|
||||||
|
name={dsp.name}
|
||||||
|
onSubmit={(pw: string) => api.connectWithPassword(pw)}
|
||||||
|
onCancel={() => api.cancelPassword()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
.password-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
padding: var(--space-4);
|
||||||
|
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog {
|
||||||
|
width: 340px;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
animation: password-dialog-enter 120ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
|
||||||
|
.password-dialog__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
gap: var(--space-3);
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__header-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__title {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__type {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
|
||||||
|
.password-dialog__content {
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__field label {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password */
|
||||||
|
|
||||||
|
.password-dialog__password-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--card-control-height);
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
|
||||||
|
padding-right: calc(var(--space-3) + 28px);
|
||||||
|
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-base) var(--ease-standard),
|
||||||
|
box-shadow var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-wrap input:focus {
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px
|
||||||
|
color-mix(in srgb, var(--accent-brand) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-wrap input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: var(--space-2);
|
||||||
|
|
||||||
|
transform: translateY(-50%);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
color: var(--accent-brand);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-toggle:hover {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-toggle svg {
|
||||||
|
width: auto;
|
||||||
|
height: 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
|
||||||
|
.password-dialog__actions {
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation */
|
||||||
|
|
||||||
|
@keyframes password-dialog-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-wrap input::-ms-reveal,
|
||||||
|
.password-dialog__password-wrap input::-ms-clear {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-wrap input::-webkit-credentials-auto-fill-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import Button from '../../../../components/Button/Button';
|
||||||
|
import Card from '../../../../components/Card/Card';
|
||||||
|
import { EyeIcon, EyeOffIcon } from '../../../../assets/icons/EyesIcons';
|
||||||
|
|
||||||
|
import './PasswordDialog.css';
|
||||||
|
|
||||||
|
export function PasswordDialog({
|
||||||
|
name,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
onSubmit: (password: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (password.length === 4) {
|
||||||
|
onSubmit(password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = (value: string) => {
|
||||||
|
if (/^[\x20-\x7E]*$/.test(value) && value.length <= 4) {
|
||||||
|
setPassword(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="password-dialog-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="password-dialog-title"
|
||||||
|
onMouseDown={handleOverlayClick}
|
||||||
|
>
|
||||||
|
<div className="password-dialog-wrapper">
|
||||||
|
<Card className="password-dialog">
|
||||||
|
<div className="password-dialog__header">
|
||||||
|
<div className="password-dialog__header-text">
|
||||||
|
<h1 id="password-dialog-title" className="password-dialog__title">
|
||||||
|
Device Locked
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="password-dialog__type" title={name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="password-dialog__content">
|
||||||
|
<div className="password-dialog__field">
|
||||||
|
<label htmlFor="device-password">Password</label>
|
||||||
|
|
||||||
|
<div className="password-dialog__password-wrap">
|
||||||
|
<input
|
||||||
|
id="device-password"
|
||||||
|
type={isPasswordVisible ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
maxLength={4}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => handlePasswordChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && password.length === 4) {
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="password-dialog__password-toggle"
|
||||||
|
onClick={() => setIsPasswordVisible((visible) => !visible)}
|
||||||
|
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||||
|
aria-pressed={isPasswordVisible}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="password-dialog__actions">
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={password.length !== 4}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Unlock
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -127,16 +127,61 @@ export async function create(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
||||||
async function updateDSPState(): Promise<void> {
|
async function updateDSPState(): Promise<DSP408['state']> {
|
||||||
try {
|
try {
|
||||||
const state = await invoke<DSP408['state']>('get_dsp_state', { id: dsp.id });
|
const state = await invoke<DSP408['state']>('get_dsp_state', { id: dsp.id });
|
||||||
updateDSP(setDsps, dsp.id, (d) => ({ ...d, state }) as DSP408);
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, state }) as DSP408);
|
||||||
|
|
||||||
|
return state;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify(`Failed to fetch state: ${String(err)}`, 'error', dsp.name);
|
notify(`Failed to fetch state: ${String(err)}`, 'error', dsp.name);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLocked = (s: DSP408['state']) => s.flags.is_locked;
|
||||||
|
|
||||||
|
async function abortLocked() {
|
||||||
|
await invoke('disconnect_dsp408', { id: dsp.id }).catch(() => {});
|
||||||
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'disconnected', passwordRequired: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectInternal(password?: string) {
|
||||||
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'connecting', passwordRequired: false }));
|
||||||
|
try {
|
||||||
|
await invoke('connect_dsp408', { id: dsp.id });
|
||||||
|
|
||||||
|
let state = await updateDSPState();
|
||||||
|
|
||||||
|
if (isLocked(state)) {
|
||||||
|
if (password === undefined) {
|
||||||
|
await abortLocked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('unlock_device', { id: dsp.id, password }); // adjust name/signature
|
||||||
|
} catch {
|
||||||
|
// wrong password
|
||||||
|
}
|
||||||
|
|
||||||
|
state = await updateDSPState();
|
||||||
|
if (isLocked(state)) {
|
||||||
|
await abortLocked();
|
||||||
|
notify('Wrong password', 'error', dsp.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'connected', pollingPaused: false }));
|
||||||
|
notify('Connected', 'success', dsp.name);
|
||||||
|
startPolling(dsp, setDsps);
|
||||||
|
} catch (err) {
|
||||||
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'disconnected' }));
|
||||||
|
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'DSP408' as const,
|
type: 'DSP408' as const,
|
||||||
|
|
||||||
@@ -155,22 +200,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
|
|
||||||
updateDSPState,
|
updateDSPState,
|
||||||
|
|
||||||
async connect() {
|
connect: () => connectInternal(),
|
||||||
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'connecting' }));
|
connectWithPassword: (password: string) => connectInternal(password),
|
||||||
try {
|
cancelPassword() {
|
||||||
await invoke('connect_dsp408', { id: dsp.id });
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, passwordRequired: false }));
|
||||||
|
|
||||||
await updateDSPState();
|
|
||||||
|
|
||||||
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'connected', pollingPaused: false }));
|
|
||||||
|
|
||||||
notify('Connected', 'success', dsp.name);
|
|
||||||
|
|
||||||
startPolling(dsp, setDsps);
|
|
||||||
} catch (err) {
|
|
||||||
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'disconnected' }));
|
|
||||||
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ interface DSPBase {
|
|||||||
deviceId: number;
|
deviceId: number;
|
||||||
mode: DSPMode;
|
mode: DSPMode;
|
||||||
|
|
||||||
|
passwordRequired?: boolean;
|
||||||
|
|
||||||
status: DSPStatus;
|
status: DSPStatus;
|
||||||
|
|
||||||
pollingPaused: boolean;
|
pollingPaused: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user