feat: add locking styles

This commit is contained in:
Lucas
2026-09-28 16:41:16 +02:00
parent 24830172ae
commit d5a2afdea5
6 changed files with 408 additions and 37 deletions
@@ -29,3 +29,9 @@
.app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay {
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 './DSP408Panel.css';
import MiscellaneousPanel from '../MiscellaneousSection/MiscellaneousSection';
import { PasswordDialog } from '../PasswordDialog/PasswordDialog';
function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
const [activeSidebar, setActiveSidebar] = useState<SidebarId>('overview');
@@ -208,6 +209,15 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
>
<div className="dsp-content">{renderTab()}</div>
</LoadingOverlay>
{dsp.passwordRequired && (
<PasswordDialog
name={dsp.name}
onSubmit={(pw: string) => api.connectWithPassword(pw)}
onCancel={() => api.cancelPassword()}
/>
)}
</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>
);
}
+50 -17
View File
@@ -127,16 +127,61 @@ export async function create(params: {
}
export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
async function updateDSPState(): Promise<void> {
async function updateDSPState(): Promise<DSP408['state']> {
try {
const state = await invoke<DSP408['state']>('get_dsp_state', { id: dsp.id });
updateDSP(setDsps, dsp.id, (d) => ({ ...d, state }) as DSP408);
return state;
} catch (err) {
notify(`Failed to fetch state: ${String(err)}`, 'error', dsp.name);
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 {
type: 'DSP408' as const,
@@ -155,22 +200,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
updateDSPState,
async connect() {
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'connecting' }));
try {
await invoke('connect_dsp408', { id: dsp.id });
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);
}
connect: () => connectInternal(),
connectWithPassword: (password: string) => connectInternal(password),
cancelPassword() {
updateDSP(setDsps, dsp.id, (d) => ({ ...d, passwordRequired: false }));
},
async disconnect() {
+2
View File
@@ -38,6 +38,8 @@ interface DSPBase {
deviceId: number;
mode: DSPMode;
passwordRequired?: boolean;
status: DSPStatus;
pollingPaused: boolean;