Skip to content
Closed
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
32 changes: 23 additions & 9 deletions src/features/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FormEvent, useState } from 'react';
import { Alert, Button, Form, Spinner } from 'react-bootstrap';
import { Alert, Button, Form, InputGroup, Spinner } from 'react-bootstrap';
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import EyeIcon from '../shared/components/EyeIcon';
import useAppParams from '../shared/hooks/useAppParams';
import { useSetLoginCredentialsMutation } from '../store/apiSlice';
import {
Expand Down Expand Up @@ -33,6 +34,7 @@ const LoginForm = () => {

const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);

Expand Down Expand Up @@ -102,14 +104,26 @@ const LoginForm = () => {
</Form.Group>
<Form.Group className="mb-4" controlId="password">
<Form.Label>Password</Form.Label>
<Form.Control
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
<InputGroup>
<Form.Control
type={showPassword ? "text" : "password"}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowPassword((prev) => !prev)}
disabled={isLoading}
aria-label={showPassword ? "Hide password" : "Show password"}
tabIndex={-1}
>
Comment on lines +119 to +123
<EyeIcon slashed={showPassword} />
</Button>
</InputGroup>
</Form.Group>
<Button type="submit" className="w-100" disabled={isLoading}>
{isLoading ? (
Expand Down
65 changes: 65 additions & 0 deletions src/features/MultiviewLayoutCanvas.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
.canvas {
position: relative;
width: 100%;
background: #111;
overflow: hidden;
}

.canvasLight {
background: #ddd;
}

.tile {
position: absolute;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.35);
display: flex;
align-items: flex-end;
padding: 3px 5px;
font-size: 0.68rem;
color: #fff;
background-color: rgba(13, 110, 253, 0.35);
cursor: pointer;
transition: outline-color 0.1s, background-color 0.1s;
outline: 2px solid transparent;
outline-offset: -2px;

&:hover {
background-color: rgba(13, 110, 253, 0.5);
}
}

.tileEmpty {
background-color: rgba(120, 120, 120, 0.15);

&:hover {
background-color: rgba(120, 120, 120, 0.28);
}
}

.tileSelected {
outline-color: #ffc107;
background-color: rgba(255, 193, 7, 0.45);
}

.tileLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}

.tileNumberBadge {
position: absolute;
top: 2px;
left: 3px;
font-size: 0.62rem;
opacity: 0.75;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}

.canvasMeta {
font-size: 0.68rem;
}

78 changes: 78 additions & 0 deletions src/features/MultiviewLayoutCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { MultiviewLayoutState, MultiviewTileState } from "../store/apiSlice";
import styles from "./MultiviewLayoutCanvas.module.scss";

export interface MultiviewLayoutCanvasProps {
/** Current multiview canvas/tile layout to render. */
layout: MultiviewLayoutState;
/** Resolves a device key (e.g. a tile's sourceDeviceKey) to a display name. */
resolveSourceName: (deviceKey: string) => string;
darkMode?: boolean;
/** Tile number to render as selected/highlighted, or null/undefined if none. */
selectedTileNumber?: number | null;
/** Called when a tile is clicked, with the full tile state. */
onTileClick?: (tile: MultiviewTileState) => void;
}

/**
* Renders a visual mock-up of what is actually displayed on the monitor fed by a single
* multiview-capable decoder: its canvas at the correct aspect ratio, with every tile
* positioned/sized/stacked to match and labeled with its routed source. Used inside a per-node
* popover in RoutingDeviceNode - purely additive, it does not alter the existing tie-line/route
* graph visualization in Routing.tsx.
*/
const MultiviewLayoutCanvas = ({
layout,
resolveSourceName,
darkMode,
selectedTileNumber,
onTileClick,
}: MultiviewLayoutCanvasProps) => {
const aspectRatio = layout.canvasWidth / layout.canvasHeight;

return (
<div>
<div className={`text-muted mb-1 ${styles.canvasMeta}`}>
{layout.canvasWidth}&times;{layout.canvasHeight}
</div>
<div
className={`${styles.canvas}${darkMode ? "" : ` ${styles.canvasLight}`}`}
style={{ aspectRatio: Number.isFinite(aspectRatio) && aspectRatio > 0 ? aspectRatio : 16 / 9 }}
>
{[...layout.tiles]
.sort((a, b) => a.zOrder - b.zOrder)
.map((tile) => {
const isEmpty = !tile.sourceDeviceKey;
const isSelected = selectedTileNumber === tile.tileNumber;
const sourceName = tile.sourceDeviceKey
? resolveSourceName(tile.sourceDeviceKey)
: "Empty";

return (
<div
key={tile.tileNumber}
className={`${styles.tile}${isEmpty ? ` ${styles.tileEmpty}` : ""}${isSelected ? ` ${styles.tileSelected}` : ""}`}
style={{
left: `${(tile.x / layout.canvasWidth) * 100}%`,
top: `${(tile.y / layout.canvasHeight) * 100}%`,
width: `${(tile.width / layout.canvasWidth) * 100}%`,
height: `${(tile.height / layout.canvasHeight) * 100}%`,
zIndex: tile.zOrder,
}}
title={`Tile ${tile.tileNumber}: ${sourceName}`}
onClick={(e) => {
e.stopPropagation();
onTileClick?.(tile);
}}
>
<span className={styles.tileNumberBadge}>{tile.tileNumber}</span>
<span className={styles.tileLabel}>{sourceName}</span>
</div>
);
})}
</div>
</div>
);
};

export default MultiviewLayoutCanvas;

65 changes: 65 additions & 0 deletions src/features/MultiviewLayoutPanel.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
.panel {
position: absolute;
width: 280px;
z-index: 20;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
border-radius: 6px;
overflow: hidden;
background: #fff;
border: 1px solid #ccc;
}

.panelDark {
background: #1e1e1e;
border-color: #444;
color: #e0e0e0;
}

.titleBar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0.5rem;
font-size: 0.78rem;
font-weight: 600;
cursor: move;
-webkit-user-select: none;
user-select: none;
background: #e9ecef;
border-bottom: 1px solid #ccc;
}

.titleBarDark {
background: #2c2c2c;
border-bottom-color: #444;
color: #e0e0e0;
}

.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-grow: 1;
margin-right: 0.5rem;
}

.closeBtn {
flex-shrink: 0;
background: none;
border: none;
padding: 0 2px;
line-height: 1;
font-size: 1.1rem;
color: inherit;
opacity: 0.7;
cursor: pointer;

&:hover {
opacity: 1;
color: #dc3545;
}
}

.body {
padding: 0.5rem;
}
106 changes: 106 additions & 0 deletions src/features/MultiviewLayoutPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useCallback, useRef } from "react";

import { MultiviewLayoutState, MultiviewTileState } from "../store/apiSlice";
import MultiviewLayoutCanvas from "./MultiviewLayoutCanvas";
import styles from "./MultiviewLayoutPanel.module.scss";

export interface MultiviewLayoutPanelPosition {
x: number;
y: number;
}

export interface MultiviewLayoutPanelProps {
title: string;
layout: MultiviewLayoutState;
position: MultiviewLayoutPanelPosition;
darkMode?: boolean;
selectedTileNumber?: number | null;
resolveSourceName: (deviceKey: string) => string;
onTileClick?: (tile: MultiviewTileState) => void;
onClose: () => void;
onMove: (position: MultiviewLayoutPanelPosition) => void;
}

/**
* A floating, freely-draggable window showing a single device's multiview canvas/tile mock-up
* (via MultiviewLayoutCanvas). Rendered as a sibling of the React Flow canvas (not as part of a
* graph node), so its position is independent of node positions - which move whenever the dagre
* layout re-runs in response to routing/filter changes. Stays open until explicitly closed, and
* multiple panels (one per device) can be open and dragged around independently at once.
*/
const MultiviewLayoutPanel = ({
title,
layout,
position,
darkMode,
selectedTileNumber,
resolveSourceName,
onTileClick,
onClose,
onMove,
}: MultiviewLayoutPanelProps) => {
const dragStateRef = useRef<{ startX: number; startY: number; originX: number; originY: number } | null>(null);

const handleTitleBarPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
// Only left-click / primary touch should start a drag.
if (e.button !== 0) return;

dragStateRef.current = {
startX: e.clientX,
startY: e.clientY,
originX: position.x,
originY: position.y,
};

const handlePointerMove = (moveEvent: PointerEvent) => {
const dragState = dragStateRef.current;
if (!dragState) return;
onMove({
x: dragState.originX + (moveEvent.clientX - dragState.startX),
y: dragState.originY + (moveEvent.clientY - dragState.startY),
});
};

const handlePointerUp = () => {
dragStateRef.current = null;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};

window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
},
[position.x, position.y, onMove],
);

return (
<div
className={`${styles.panel}${darkMode ? ` ${styles.panelDark}` : ""}`}
style={{ left: position.x, top: position.y }}
>
<div
className={`${styles.titleBar}${darkMode ? ` ${styles.titleBarDark}` : ""}`}
onPointerDown={handleTitleBarPointerDown}
>
<span className={styles.title} title={title}>
{title}
</span>
<button className={styles.closeBtn} onClick={onClose} title="Close">
&times;
</button>
</div>
<div className={styles.body}>
<MultiviewLayoutCanvas
layout={layout}
resolveSourceName={resolveSourceName}
darkMode={darkMode}
selectedTileNumber={selectedTileNumber}
onTileClick={onTileClick}
/>
</div>
</div>
);
};

export default MultiviewLayoutPanel;
Loading