Compare commits

..

4 Commits

Author SHA1 Message Date
Claude 4e2baf1fa1 feat(network): add session status card
Display server health metrics from the unused /session endpoint:
- Connected clients count (live from useClientStore)
- Server uptime since last restart
- Last client connection time (relative)
- Last integration request time (relative)
- Server timezone
- Cloud latency (Docker only) with warning threshold

Adds useSessionStats hook with 60s refetch interval, new NetworkStatus
component as first card in Network panel, and navigation menu entries
for Server status and Network interfaces sections.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 18:28:15 +00:00
Claude 46e4ac94f1 feat(log): add filters to the event log and cap the log store
Finding a single error in a stream of RX/TX traffic was not practical
with only the origin filters.

- add a text filter and an issues only filter
- show how many entries are being displayed
- add an empty state which distinguishes no activity from no matches
- collapse the six origin flags into a single set
- cap the log store at 500 entries, it was growing unbounded for the
  duration of a session
- the event log button was labelled Extract, which did not convey that
  it opens the log in a new window

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 13:20:46 +00:00
Claude b5e1bccc70 feat(network): help users orient themselves in the network panel
- add a network interfaces card to the network panel. The addresses
  users need to reach Ontime from other devices were only available
  in the sharing panel
- show the port along with the address, the bare IP is not enough to
  reach the server
- explain what each section does and how the client actions behave
- the client path was selectable text but could not be opened or
  copied. It now uses a copy tag with the full client URL, which also
  makes the client origin visible in setups with several interfaces

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 13:18:52 +00:00
Claude c840add9c5 fix(network): correct defects in client management UI
- self row highlight lost a specificity fight with the table zebra
  striping, so the current client was only highlighted on odd rows.
  Reuse the data attribute pattern already used for data-warn
- drop reference to a `blink` class which was never defined, the button
  variant already communicates the identify state
- use `th` in table headers so the shared table header styling applies,
  matching the other settings panels
- add empty states to both client tables
- keep a stable row order (self first, then by name) so rows do not
  move as clients connect and disconnect
- the redirect dialog disabled its view select when no URL presets
  existed, making it impossible to redirect to a plain view. Its
  default value also disagreed with component state, leaving the
  button disabled until the visible value was re-picked
- rename dialog now autofocuses and submits on Enter
- move the cloud ping into a card, matching the panel conventions, and
  flag high latency values

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 13:15:43 +00:00
316 changed files with 6097 additions and 11026 deletions
-46
View File
@@ -1,46 +0,0 @@
---
name: code-review
description: Review Ontime changes for concrete correctness, architecture, testing, routing, security, and maintenance issues. For GitHub Copilot review.
---
# Review Ontime changes
Review as a maintainer. Find material defects, not speculative advice, style preferences, or generic checklists. Approve when no material issue remains.
## Load relevant context
Read the full diff, PR description, linked issue, tests, and nearby owning code. Then load only applicable guides:
- Every non-trivial review: [change assessment](../../../docs/agent-guides/change-assessment.md)
- Module placement, server layers, client state, shared packages: [architecture](../../../docs/agent-guides/architecture.md)
- Tests or changed behaviour: [testing](../../../docs/agent-guides/testing.md)
- Comments, abstractions, naming, complexity: [code quality](../../../docs/agent-guides/code-quality.md)
- Authentication, external input, files, integrations, assets, secrets: [security](../../../docs/agent-guides/security.md)
- Routes, URLs, websockets, authentication, cookies, presets, assets: [routing and cloud](../../../docs/agent-guides/routing-and-cloud.md)
- Rundowns, timers, persistence, imports, cache, realtime: [domain invariants](../../../docs/agent-guides/domain-invariants.md)
- Commands, imports, dependencies, formatting, CI claims: [workflow](../../../docs/agent-guides/workflow.md)
Check a guide and nearby canonical code before citing an Ontime convention. Skip unrelated guides.
## Review order
1. Establish intent and affected runtime surfaces.
2. Read tests first. Identify claimed behaviour and coverage.
3. Trace implementation, errors, and state transitions.
4. Check correctness/data integrity, security, architecture/testability, cloud routing, lifecycle/performance, maintainability.
5. Verify claimed checks. Never claim unobserved results.
Passing tests do not prove architecture, routing, comments, or error paths. Review changed behaviour only; include existing problems only when the diff worsens or relies on them.
## Findings
Report only concrete, actionable issues. Each finding: tight line range, direct defect, impact and trigger, smallest viable remedy when unclear.
- **P0 — Critical:** data loss, exploitable vulnerability, broadly broken production. Blocks merge.
- **P1 — High:** likely correctness failure or major supported-deployment regression. Blocks merge.
- **P2 — Medium:** real edge-case defect, architecture regression, missing business-rule test, stale comment, meaningful maintenance risk. Normally blocks merge.
- **P3 — Low:** local improvement with limited impact. No subjective style or tool-managed formatting.
Order by priority. Prefer few high-confidence findings. No praise or checklist before findings. If none, say so and note verification gaps or residual risk.
Then give one concise PR-level value/risk/complexity assessment. Never repeat it per finding.
-22
View File
@@ -1,22 +0,0 @@
# Ontime agent guide
Make the smallest maintainable change. Keep scope narrow. Inspect nearby code first. Reuse helpers and boundaries when semantics match.
## Load only relevant guides
- Commands, validation, formatting, imports, PRs: [workflow](docs/agent-guides/workflow.md).
- Non-trivial planning, implementation, review: [change assessment](docs/agent-guides/change-assessment.md).
- Module placement, server layers, client state, shared packages: [architecture](docs/agent-guides/architecture.md).
- Tests or changed behaviour: [testing](docs/agent-guides/testing.md).
- Comments, naming, abstractions, maintainability: [code quality](docs/agent-guides/code-quality.md).
- Authentication, external input, files, integrations, assets, secrets: [security](docs/agent-guides/security.md).
- Navigation, URLs, API paths, websockets, redirects, cookies, static assets: [routing and cloud](docs/agent-guides/routing-and-cloud.md).
- Rundowns, timers, imports, persistence, cache, websockets: [domain invariants](docs/agent-guides/domain-invariants.md).
Load multiple guides when needed. Skip unrelated guides for mechanical work.
## Before handoff
- Check the final diff for scope, stale comments, temporary code, redundant tests, generated files.
- If work reveals a missing, stable, reusable system, domain, or product invariant, update its owning guide. Exclude guesses, one-off bugs, and implementation details.
- Follow [workflow verification](docs/agent-guides/workflow.md). Report only observed results.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.13.0",
"version": "4.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -4
View File
@@ -7,15 +7,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Ontime" />
<link rel="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="manifest" href="manifest.json" />
<meta name="robots" content="noindex" />
<title>Ontime</title>
<title>ontime</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+8 -9
View File
@@ -1,10 +1,10 @@
{
"name": "ontime-ui",
"version": "4.13.0",
"version": "4.11.0",
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.7.0",
"@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -13,12 +13,12 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^9.5.1",
"@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^9.1.2",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1",
"axios": "^1.18.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0",
"react-icons": "5.7.0",
"react-icons": "5.6.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
@@ -60,8 +60,7 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@sentry/vite-plugin": "5.1.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
@@ -73,8 +72,8 @@
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "8.2.1",
"vite-plugin-compression2": "2.5.3",
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0",
"vitest": "catalog:"
}
+7 -9
View File
@@ -1,21 +1,19 @@
{
"name": "Ontime",
"short_name": "Ontime",
"name": "ontime",
"short_name": "ontime",
"icons": [
{
"src": "ontime-logo-192.png",
"sizes": "192x192",
"type": "image/png"
"src": "favicon.ico",
"type": "image/x-icon"
},
{
"src": "ontime-logo-512.png",
"sizes": "512x512",
"src": "ontime-logo.png",
"type": "image/png"
}
],
"scope": "./",
"start_url": "./",
"display": "standalone",
"theme_color": "#101010",
"display": "",
"theme_color": "#121212",
"background_color": "#101010"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

+8
View File
@@ -0,0 +1,8 @@
{
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
-3
View File
@@ -3,7 +3,6 @@ import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { EditableRundownScopeProvider } from './common/context/EditableRundownScopeProvider';
import { PresetContext } from './common/context/PresetContext';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath';
@@ -113,9 +112,7 @@ export default function AppRouter() {
path='rundown'
element={
<EditorFeatureWrapper>
<EditableRundownScopeProvider rundownId={null}>
<RundownPanel />
</EditableRundownScopeProvider>
</EditorFeatureWrapper>
}
/>
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,25 +0,0 @@
import { maybeAxiosError } from '../utils';
describe('maybeAxiosError', () => {
it('shows the validation message without echoing the submitted value', () => {
const error = {
isAxiosError: true,
response: {
statusText: 'Unprocessable Entity',
data: {
errors: [
{
type: 'field',
value: { title: 'Automation definition', outputs: [{ targetIP: 'not a host' }] },
msg: 'Invalid OSC target',
path: '',
location: 'body',
},
],
},
},
};
expect(maybeAxiosError(error)).toBe('Unprocessable Entity: Invalid OSC target');
});
});
+1 -8
View File
@@ -15,6 +15,7 @@ export const RUNDOWN = ['rundown'];
export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current'];
export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId];
export const RUNTIME = ['runtimeStore'];
export const SESSION_STATS = ['sessionStats'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
@@ -22,14 +23,6 @@ export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
/**
* Cache key holding the data for a rundown.
* Before the loaded rundown is known there is no id to key by,
* and the data lives under the bootstrap alias.
*/
export const getRundownCacheKey = (rundownId: string) =>
rundownId ? getRundownQueryKey(rundownId) : CURRENT_RUNDOWN_QUERY_KEY;
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
+8 -3
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import type { ReportData } from 'ontime-types';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants';
@@ -8,13 +8,18 @@ import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch the report
* HTTP request to fetch all reports
*/
export async function fetchReport(options?: RequestOptions): Promise<ReportData> {
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
-7
View File
@@ -176,13 +176,6 @@ export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
}
/**
* HTTP request events duration to fit inside the group target
*/
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
}
/**
* HTTP request for grouping a list of entries into a group
*/
+9 -1
View File
@@ -1,11 +1,19 @@
import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types';
import { GetInfo, LinkOptions, SessionStats } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve statistics of the running session
*/
export async function getSessionStats(options?: RequestOptions): Promise<SessionStats> {
const res = await axios.get(sessionPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to retrieve application info
*/
-9
View File
@@ -18,15 +18,6 @@ export function maybeAxiosError(error: unknown) {
if (typeof data === 'object') {
if ('message' in data) {
data = JSON.stringify(data.message);
} else if ('errors' in data && Array.isArray(data.errors)) {
const firstError = data.errors.at(0);
data =
typeof firstError === 'object' &&
firstError !== null &&
'msg' in firstError &&
typeof firstError.msg === 'string'
? firstError.msg
: JSON.stringify(data);
} else {
data = JSON.stringify(data);
}
@@ -1,18 +0,0 @@
import { ComponentProps } from 'react';
import Button from './Button';
type ToggleButtonProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
/** whether the option this button controls is currently on */
pressed: boolean;
};
/**
* A button which carries an on / off state.
*
* Keeps the pressed styling and the accessible state together, so that a toggle
* cannot end up looking active without also announcing that it is.
*/
export default function ToggleButton({ pressed, ...buttonProps }: ToggleButtonProps) {
return <Button variant={pressed ? 'primary' : 'subtle'} aria-pressed={pressed} {...buttonProps} />;
}
@@ -1,82 +1,24 @@
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background-color: $gray-1300;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
}
.sectionHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.separator {
display: flex;
align-items: center;
gap: 0.75rem;
color: $gray-500;
font-size: $inner-section-text-size;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
&::before,
&::after {
content: '';
flex: 1;
border-top: 1px solid $gray-1100;
}
}
.textEntry {
grid-area: input;
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
color: $label-gray;
}
.inlineEntry {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
grid-template-areas:
'label label'
'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
}
.redirect {
margin-left: auto;
grid-area: btn;
}
.label {
color: $gray-200;
font-size: $text-body-size;
font-weight: 600;
}
.description {
color: $gray-500;
font-size: $inner-section-text-size;
}
.origin {
flex: 0 0 auto;
color: $gray-500;
font-size: $inner-section-text-size;
white-space: nowrap;
}
@media (max-width: $min-tablet) {
.inlineEntry {
grid-template-columns: 1fr;
align-items: stretch;
}
.redirect {
align-self: flex-end;
}
color: $label-gray;
}
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
import { baseURI } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { setClientRemote } from '../../hooks/useSocket';
@@ -9,11 +8,14 @@ import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info';
import Input from '../input/input/Input';
import ExternalLink from '../link/external-link/ExternalLink';
import AppLink from '../link/app-link/AppLink';
import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
/** the first navigator view, used as the initial value of the view select */
const defaultView = `/${navigatorConstants[0].url}`;
interface RedirectClientModalProps {
id: string;
name: string;
@@ -26,7 +28,7 @@ interface RedirectClientModalProps {
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
const [selected, setSelected] = useState(defaultView);
const { setRedirect } = setClientRemote;
@@ -45,7 +47,6 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
};
const enabledPresets = data.filter((preset) => preset.enabled);
const settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`;
const viewOptions = [
...navigatorConstants.map((view) => ({
@@ -68,26 +69,21 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
bodyElements={
<>
<Info>
<Info.Body>
Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset.
</Info.Body>
<Info.Footer>
<ExternalLink href={settingsUrl}>Manage URL Presets</ExternalLink>
</Info.Footer>
Remotely redirect the client to a different URL. <br />
Either by entering a custom path or selecting a URL Preset.
<br />
<br />
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info>
<section className={style.section}>
<div className={style.sectionHeader}>
<span className={style.label}>Enter custom path</span>
<span className={style.description}>Type the path the client should open.</span>
</div>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
<span className={style.origin}>{origin}</span>
{origin}
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
</label>
<Button
variant='primary'
aria-label='Redirect to custom path'
aria-label='Redirect'
disabled={path === currentPath || path === ''}
className={style.redirect}
onClick={() => handleRedirect(path)}
@@ -96,40 +92,32 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<IoArrowForward />
</Button>
</div>
</section>
<div className={style.separator} role='separator'>
<span>Or</span>
</div>
<section className={style.section}>
<div className={style.sectionHeader}>
<span className={style.label}>Select view or URL Preset</span>
<span className={style.description}>Choose from the available destinations.</span>
</div>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.inlineEntry}>
<label className={style.textEntry}>
<span className={style.origin}>{origin}</span>
{origin}
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
defaultValue={defaultView}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
disabled={enabledPresets.length === 0}
/>
</label>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
disabled={selected === currentPath}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
</Button>
</div>
</section>
</div>
</>
}
/>
@@ -1,13 +0,0 @@
@use '../../../theme/ontimeStyles' as *;
.form {
padding: 0.25rem 0.5rem 0.5rem;
}
.label {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: $label-gray;
font-size: $inner-section-text-size;
}
@@ -2,11 +2,10 @@ import { useState } from 'react';
import Button from '../../../common/components/buttons/Button';
import { setClientRemote } from '../../hooks/useSocket';
import { isKeyEnter } from '../../utils/keyEvent';
import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps {
id: string;
name?: string;
@@ -33,22 +32,20 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
isOpen={isOpen}
title={`Rename Client: ${currentName}`}
showCloseButton
showBackdrop
onClose={onClose}
bodyElements={
<div className={style.form}>
<label className={style.label}>
Client name
<Input
height='large'
fluid
autoComplete='off'
placeholder='New name'
autoFocus
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (isKeyEnter(event) && canSubmit) {
handleRename();
}
}}
/>
</label>
</div>
}
footerElements={
<>
@@ -1,16 +1,12 @@
.dialog {
position: fixed;
top: 10vh;
top: 10%;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
width: min(600px, 90vw);
max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
padding-inline: 1rem;
min-width: min(600px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -47,8 +43,7 @@
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-height: 0;
max-height: min(80vh, 600px);
overflow-y: auto;
}
@@ -58,5 +53,4 @@
align-items: center;
justify-content: end;
gap: 1rem;
flex-wrap: wrap;
}
@@ -1,6 +0,0 @@
.eyebrow {
font-size: $aux-text-size;
font-weight: 400;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -1,13 +0,0 @@
import { PropsWithChildren } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './Eyebrow.module.scss';
interface EyebrowProps {
className?: string;
}
export default function Eyebrow({ children, className }: PropsWithChildren<EyebrowProps>) {
return <span className={cx([style.eyebrow, className])}>{children}</span>;
}
@@ -21,15 +21,4 @@
outline: none;
box-shadow: 0 1px 0 0 currentColor;
}
&:hover .icon,
&:focus-visible .icon {
transform: translateX(0.25rem);
}
}
.icon {
flex: 0 0 1.5em;
font-size: 1em;
transition: transform $transition-time-action;
}
@@ -1,5 +1,5 @@
import { MouseEvent, ReactNode } from 'react';
import { HiArrowLongRight } from 'react-icons/hi2';
import { IoOpenOutline } from 'react-icons/io5';
import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils';
@@ -10,10 +10,9 @@ interface ExternalLinkProps {
href: string;
children: ReactNode;
inline?: boolean;
className?: string;
}
export default function ExternalLink({ href, inline, className, children }: ExternalLinkProps) {
export default function ExternalLink({ href, inline, children }: ExternalLinkProps) {
const handleClick = (event: MouseEvent) => {
event.preventDefault();
openLink(href);
@@ -24,11 +23,10 @@ export default function ExternalLink({ href, inline, className, children }: Exte
href='#!'
target='_blank'
rel='noreferrer'
className={cx([style.link, inline && style.inline, className])}
className={cx([style.link, inline && style.inline])}
onClick={handleClick}
>
{children}
<HiArrowLongRight className={style.icon} />
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
</a>
);
}
@@ -4,13 +4,10 @@
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
width: min(880px, 90vw);
padding-inline: 1rem;
min-width: min(880px, 90vw);
min-height: min(200px, 10vh);
max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
max-width: min(1200px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -20,21 +17,14 @@
outline: none;
}
.compact {
min-width: 0;
width: min(680px, 90vw);
}
.small {
min-width: 0;
width: min(480px, 90vw);
}
.wide {
top: 4vh;
width: min(1800px, 98vw);
height: min(88vh, calc(96vh - 1rem));
min-height: 0;
min-width: min(1280px, 96vw);
max-width: min(1800px, 98vw);
height: 88vh;
max-height: 88vh;
display: flex;
flex-direction: column;
}
.backdrop {
@@ -51,19 +41,15 @@
.title {
padding-block: 1rem;
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.titleText {
min-width: 0;
font-size: 1rem;
font-weight: 600;
overflow-wrap: anywhere;
}
.body {
@@ -71,23 +57,21 @@
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-height: 0;
padding: 0.5rem 1rem;
max-height: 60vh;
overflow-y: auto;
scroll-padding-block: 0.5rem;
}
.wide .body {
flex: 1;
min-height: 0;
max-height: none;
overflow: hidden;
}
.footer {
padding-block: 1rem;
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: end;
gap: 1rem;
flex-wrap: wrap;
}
@@ -12,7 +12,7 @@ interface ModalProps {
title?: string;
showCloseButton?: boolean;
showBackdrop?: boolean;
size?: 'small' | 'compact' | 'default' | 'wide';
size?: 'default' | 'wide';
bodyElements: ReactNode;
footerElements?: ReactNode;
onClose: () => void;
@@ -38,7 +38,7 @@ export default function Modal({
>
<BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup aria-label={title} className={cx([style.modal, style[size]])}>
<BaseDialog.Popup aria-label={title} className={cx([style.modal, size === 'wide' && style.wide])}>
<div className={style.title}>
{title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />}
{showCloseButton && (
@@ -9,7 +9,6 @@ import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: MaybeNumber;
complete: MaybeNumber;
eventId?: string | null;
normalColor: string;
warning?: MaybeNumber;
warningColor: string;
@@ -25,7 +24,6 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const {
now,
complete,
eventId,
normalColor,
warning,
warningColor,
@@ -37,7 +35,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '',
} = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete, eventId);
const percentRemaining = 100 - useAnimatedProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0;
@@ -1,3 +1,7 @@
.separator {
border-color: $border-color-ondark;
}
.backdrop {
position: fixed;
inset: 0;
@@ -19,15 +23,14 @@
width: 22rem;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
padding-block: 1rem;
background-color: $gray-1250;
color: $ui-white;
border-right: 1px solid $gray-1100;
box-shadow: $box-shadow-right;
&[data-open] {
transform: translateX(0%);
@@ -47,51 +50,22 @@
}
.header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
border-bottom: 1px solid $white-10;
}
height: 3.5rem;
padding-inline: 1.5rem;
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1.25rem;
line-height: 1.2;
}
.clientName {
font-size: $aux-text-size;
color: $gray-600;
@include ellipsis-text;
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-block: 0.5rem 1rem;
}
.group {
display: flex;
flex-direction: column;
&:not(:first-child) {
margin-top: 0.75rem;
}
}
.groupLabel {
padding: 0.5rem 1.25rem 0.25rem;
color: $gray-700;
.note {
margin-left: auto;
color: $white-20;
}
@@ -1,7 +1,7 @@
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { memo, PropsWithChildren } from 'react';
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router';
@@ -14,11 +14,9 @@ import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import Eyebrow from '../eyebrow/Eyebrow';
import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss';
@@ -35,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreenDocument();
const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
@@ -54,40 +52,34 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
<Dialog.Popup className={style.drawer}>
<div className={style.header}>
<div className={style.headerText}>
<Dialog.Title className={style.title}>Ontime</Dialog.Title>
{name && <span className={style.clientName}>{name}</span>}
</div>
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
<Dialog.Title>Ontime</Dialog.Title>
<IconButton variant='subtle-white' size='large' onClick={onClose}>
<IoClose />
</IconButton>
</div>
<div className={style.body}>
<MenuGroup label='This screen'>
{supportsFullscreen && (
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
)}
<NavigationMenuToggle
checked={mirror}
icon={<IoSwapVertical />}
label='Flip Screen'
onToggle={() => toggleMirror()}
/>
{canUseWakeLock && (
<NavigationMenuToggle
checked={keepAwake}
icon={<LuCoffee />}
label='Keep Awake'
onToggle={toggleKeepAwake}
/>
)}
<NavigationMenuItem onClick={handlers.open}>
<IoPencilOutline />
Rename Client
<NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
</MenuGroup>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
{keepAwake && <span className={style.note}>Active</span>}
</NavigationMenuItem>
)}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
<hr className={style.separator} />
<MenuGroup label='Ontime app'>
<EditorNavigation />
<ClientLink
to='cuesheet'
@@ -97,17 +89,13 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink
to='op'
current={location.pathname === '/op'}
postAction={isSmallScreen ? onClose : undefined}
>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline />
Operator
</ClientLink>
</MenuGroup>
<MenuGroup label='Views'>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<ClientLink
key={route.url}
@@ -118,29 +106,21 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
{route.label}
</ClientLink>
))}
</MenuGroup>
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div>
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
{isLocalhost && (
<div>
<OtherAddresses currentLocation={location.pathname} />
</div>
)}
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.group}>
<div className={style.groupLabel}>
<Eyebrow>{label}</Eyebrow>
</div>
{children}
</div>
);
}
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
@@ -149,7 +129,8 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
if (navPresets.length === 0) return null;
return (
<MenuGroup label='Presets'>
<>
<hr className={style.separator} />
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
@@ -160,6 +141,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
{preset.alias}
</ClientLink>
))}
</MenuGroup>
</>
);
}
@@ -1,8 +1,8 @@
import { useHotkeys } from '@mantine/hooks';
import { memo, useCallback, useState } from 'react';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams } from '../../stores/savedViewParams';
import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu';
@@ -15,37 +15,22 @@ interface ViewNavigationMenuProps {
suppressSettings?: boolean;
}
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.closest(
'button, a, input, textarea, select, [role="button"], [role="checkbox"], [role="switch"], [contenteditable]:not([contenteditable="false"])',
) !== null
);
}
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams);
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
const savedParams = useSavedViewParams((store) => store.params);
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
useHotkeys([
[
'Space',
(event) => {
if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
event.preventDefault();
toggleMenu();
() => {
if (isNavigationLocked) return;
menuHandler.toggle();
},
{ preventDefault: false },
{ preventDefault: true },
],
[
'mod + ,',
@@ -64,11 +49,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
return (
<>
<FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : toggleMenu}
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges}
/>
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</>
);
}
@@ -2,68 +2,35 @@
display: flex;
align-items: center;
padding: 0.625rem 1.25rem;
gap: 0.625rem;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
width: 100%;
border-left: 3px solid transparent;
color: $gray-200;
font-size: $text-body-size;
border-left: 4px solid transparent;
color: $action-text-color;
white-space: nowrap;
text-align: left;
cursor: pointer;
transition-property: color, background-color;
transition-property: color;
transition-duration: $transition-time-action;
// keep icons on a fixed box so labels align regardless of the glyph
> svg {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
color: $gray-500;
transition: color $transition-time-action;
}
&:hover {
background-color: $white-3;
color: $ui-white;
> svg {
color: $gray-300;
}
color: $ontime-color;
background-color: $gray-1350;
}
&:active {
background-color: $white-7;
background-color: $border-color-ondark;
}
&:focus-visible {
&:focus {
outline: 2px solid $blue-500;
outline-offset: -2px;
background-color: $white-3;
}
// toggle rows are labels, the focusable control sits inside them
&:focus-within {
background-color: $white-3;
background-color: $gray-1350;
}
&.current {
background-color: $bg-active;
border-left-color: $blue-400;
color: $ui-white;
font-weight: 600;
> svg {
color: $blue-300;
}
background-color: $gray-1300;
border-left: 4px solid $action-text-color;
}
}
.label {
flex: 1;
min-width: 0;
@include ellipsis-text;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
import { isKeyEnter } from '../../../utils/keyEvent';
import { cx } from '../../../utils/styleUtils';
import style from './NavigationMenuItem.module.scss';
@@ -11,7 +11,6 @@ interface NavigationMenuItemProps {
onClick: () => void;
}
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
export default function NavigationMenuItem({
active,
className,
@@ -25,9 +24,7 @@ export default function NavigationMenuItem({
role='button'
onClick={onClick}
onKeyDown={(event) => {
if (isKeyEnter(event) || isKeySpace(event)) {
event.preventDefault();
event.stopPropagation();
if (isKeyEnter(event)) {
onClick();
}
}}
@@ -1,23 +0,0 @@
import { ReactNode } from 'react';
import Switch from '../../switch/Switch';
import style from './NavigationMenuItem.module.scss';
interface NavigationMenuToggleProps {
checked: boolean;
icon: ReactNode;
label: string;
onToggle: () => void;
}
/** A menu row which reflects, and toggles, an on/off state */
export default function NavigationMenuToggle({ checked, icon, label, onToggle }: NavigationMenuToggleProps) {
return (
<label className={style.link}>
{icon}
<span className={style.label}>{label}</span>
<Switch checked={checked} onCheckedChange={onToggle} />
</label>
);
}
@@ -1,52 +1,22 @@
.footer {
flex: 0 0 auto;
padding: 0.75rem 0;
border-top: 1px solid $white-10;
}
.header {
padding: 0 1.25rem 0.25rem;
font-size: calc(1rem - 2px);
margin-left: 1rem;
color: $gray-700;
}
.interfaces {
padding: 0.25rem 1.25rem 0;
padding: 0.5rem 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.interface {
min-width: 0;
max-width: 100%;
> div {
max-width: 100%;
> button:first-child {
min-width: 0;
max-width: calc(100% - 1.5rem);
justify-content: flex-start;
padding-inline: 0.5rem;
}
}
gap: 0.5rem;
}
.interfaceCopy {
display: flex;
align-items: center;
min-width: 0;
width: 100%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.goIcon {
flex: 0 0 auto;
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
@@ -3,8 +3,6 @@ import { IoArrowUp } from 'react-icons/io5';
import useInfo from '../../../hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
import CopyTag from '../../copy-tag/CopyTag';
import Eyebrow from '../../eyebrow/Eyebrow';
import { getExternalInterfaces } from './otherAddresses.utils';
import style from './OtherAddresses.module.scss';
@@ -14,33 +12,32 @@ interface OtherAddressesProps {
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
const { data } = useInfo();
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
if (externalInterfaces.length === 0) {
// there is no point showing this if we only have one interface
if (data.networkInterfaces.length < 2) {
return null;
}
return (
<div className={style.footer}>
<div className={style.header}>
<Eyebrow>Also available at</Eyebrow>
</div>
<>
<div className={style.header}>Accessible on external networks</div>
<div className={style.interfaces}>
{externalInterfaces.map((nif) => {
{data?.networkInterfaces?.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation);
return (
<div key={nif.name} className={style.interface}>
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}>
<span className={style.address}>{nif.address}</span>
<IoArrowUp className={style.goIcon} />
{nif.address} <IoArrowUp className={style.goIcon} />
</span>
</CopyTag>
</div>
);
})}
</div>
</div>
</>
);
}
@@ -1,31 +0,0 @@
import { describe, expect, test } from 'vitest';
import { getExternalInterfaces } from '../otherAddresses.utils';
describe('getExternalInterfaces', () => {
test('returns no alternatives when localhost is the only interface', () => {
expect(getExternalInterfaces([{ name: 'localhost', address: '127.0.0.1' }])).toEqual([]);
});
test('does not treat an interface name as deployment information', () => {
expect(
getExternalInterfaces([
{ name: 'localhost', address: '127.0.0.1' },
{ name: 'cloud', address: '192.168.1.42' },
]),
).toEqual([{ name: 'cloud', address: '192.168.1.42' }]);
});
test('returns non-local interfaces when local and external interfaces are available', () => {
expect(
getExternalInterfaces([
{ name: 'localhost', address: '127.0.0.1' },
{ name: 'Wi-Fi', address: '192.168.1.42' },
{ name: 'Ethernet', address: '10.0.0.42' },
]),
).toEqual([
{ name: 'Wi-Fi', address: '192.168.1.42' },
{ name: 'Ethernet', address: '10.0.0.42' },
]);
});
});
@@ -1,6 +0,0 @@
import { type NetworkInterface } from 'ontime-types';
/** Returns network addresses other than the localhost address injected by the info endpoint. */
export function getExternalInterfaces(interfaces: NetworkInterface[]) {
return interfaces.filter((networkInterface) => networkInterface.name !== 'localhost');
}
@@ -7,12 +7,12 @@ import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
eventId?: string | null;
className?: string;
}
export default function ProgressBar({ current, duration, eventId, className }: ProgressBarProps) {
const progress = useAnimatedProgress(current, duration, eventId);
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props;
const progress = useAnimatedProgress(current, duration);
return (
<div className={`progress-bar__bg ${className}`}>
@@ -1,43 +1,20 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer {
width: 100%;
text-align: center;
color: var(--secondary-color-override, $viewer-secondary-color);
color: $white-10;
.empty {
display: block;
width: min(100%, 14rem);
margin: 0 auto -1.5rem;
opacity: 0.6;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8;
}
.text {
display: block;
margin-inline: auto;
font-weight: 400;
font-size: clamp(1rem, 1.55vw, 1.5rem);
line-height: 1.35;
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
font-weight: 600;
font-size: 2em;
max-width: min(100%, 600px);
}
}
@@ -1,5 +1,4 @@
import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
@@ -10,18 +9,12 @@ interface EmptyProps {
text?: string;
injectedStyles?: CSSProperties;
className?: string;
variant?: 'error';
}
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
return (
<div
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
<EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>}
</div>
);
@@ -1,11 +0,0 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -1,19 +0,0 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100dvh;
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
@@ -16,6 +16,5 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-block: 5rem;
padding-top: 5rem;
}
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
injectedStyles?: CSSProperties;
variant?: 'error';
}
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
return (
<div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
<Empty text={text} injectedStyles={injectedStyles} />
</div>
);
}
@@ -15,4 +15,9 @@
gap: 1rem;
margin-top: 1em;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -11,11 +11,6 @@
color: $ui-white;
}
.active {
border: 1px solid $green-700;
color: $green-400;
}
.warning {
background-color: $orange-1300;
color: $orange-300;
@@ -6,7 +6,7 @@ import style from './Tag.module.scss';
interface TagProps {
className?: string;
variant?: 'default' | 'active' | 'warning';
variant?: 'default' | 'warning';
}
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
@@ -2,12 +2,6 @@
display: flex;
align-items: center;
gap: 0.5rem;
> span {
font-variant-numeric: tabular-nums;
text-align: right;
text-transform: uppercase;
}
}
// attempt to match with ontimeTextInputs
@@ -1,77 +1,37 @@
.multiOptionList {
overflow-y: auto;
max-height: 12rem;
background: $gray-1200;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
.inline {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
// styles from subtle button
.toggleSelect {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
background: transparent;
border: 0;
color: $gray-300;
gap: 0.25rem;
padding-inline: 0.5rem;
height: 2.5rem;
background: $gray-1050;
color: $ui-white;
line-height: 1em;
cursor: pointer;
transition:
background-color $transition-time-action,
border-color $transition-time-action,
color $transition-time-action;
border-radius: $component-border-radius-md;
&:hover {
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
color: $blue-500;
}
&:active {
&:active:not(:disabled) {
background: $gray-1100;
}
&:focus-within {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
&.selected {
background: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
}
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
> [role='checkbox'] {
flex: 0 0 auto;
width: 1.125rem;
height: 1.125rem;
border: 1px solid $gray-400;
&[data-unchecked] {
background: $gray-1100;
}
&[data-checked] {
border-color: $blue-400;
}
&:focus-visible {
outline: 0;
}
border-color: $gray-1250;
}
&::after {
content: '';
flex: 0 0 auto;
margin-left: auto;
width: 0.625rem;
height: 0.625rem;
margin-left: 0.25rem;
width: 0.75em;
height: 0.75em;
background: var(--user-bg, $gray-900);
border-radius: 50%;
}
@@ -2,7 +2,6 @@ import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../views/common/viewUtils';
import { cx } from '../../utils/styleUtils';
import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select';
@@ -108,20 +107,18 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
return (
<>
<input name={id} hidden readOnly value={paramState.join(',')} />
<div className={style.multiOptionList}>
<div className={style.inline}>
{values.map((option) => {
const selected = paramState.includes(option.value);
return (
<label
key={option.value}
className={cx([style.toggleSelect, selected && style.selected])}
className={style.toggleSelect}
style={{
'--user-bg': option.colour,
}}
>
<Checkbox
checked={selected}
checked={paramState.includes(option.value)}
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
/>
{option.label}
@@ -1,3 +1,13 @@
.drawerFooter {
display: flex;
justify-content: end;
gap: $section-spacing;
button {
padding: 0 2em;
}
}
.backdrop {
position: fixed;
inset: 0;
@@ -24,11 +34,11 @@
display: flex;
flex-direction: column;
padding-block: 1rem 1.5rem;
background-color: $ui-black;
background-color: $gray-1250;
color: $ui-white;
border-left: 1px solid $gray-1100;
box-shadow: $box-shadow-l1;
&[data-open] {
transform: translateX(0%);
@@ -48,54 +58,36 @@
}
.header {
flex: 0 0 auto;
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem;
}
height: 3.5rem;
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1.25rem;
line-height: 1.2;
}
.target {
font-size: $aux-text-size;
color: $gray-600;
}
.body {
flex: 1;
padding: 1rem;
padding-inline: 1rem;
padding-bottom: 10vh;
overflow-y: auto;
}
.footer {
flex: 0 0 auto;
display: flex;
padding: 0.75rem 1rem;
padding-inline: 1rem;
gap: 1rem;
align-items: center;
justify-content: flex-end;
background-color: $ui-black;
border-top: 1px solid $white-10;
}
.sectionList {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 2rem;
padding-right: 0.5rem;
}
.info {
@@ -4,7 +4,6 @@ import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { viewLabels } from '../../../viewerConfig';
import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
@@ -72,17 +71,8 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Dialog.Backdrop className={style.backdrop} />
<Dialog.Popup className={style.drawer}>
<div className={style.header}>
<div className={style.headerText}>
<Dialog.Title className={style.title}>Customise</Dialog.Title>
<span className={style.target}>{viewLabels[target]}</span>
</div>
<IconButton
variant='subtle-white'
size='large'
aria-label='Close'
data-testid='close-view-params'
onClick={handleClose}
>
<Dialog.Title>Customise</Dialog.Title>
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
<IoClose />
</IconButton>
</div>
@@ -1,21 +1,10 @@
.presetSection {
background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
margin-bottom: 0.75rem;
}
.header {
padding: 0.5rem 1rem;
min-height: 2.25rem;
display: flex;
align-items: center;
color: $gray-300;
}
.presetList {
border-top: 1px solid $white-7;
flex-direction: column;
gap: 0.25rem;
padding: 1rem 0.5rem;
margin-bottom: 1rem;
max-height: 10rem;
overflow-y: auto;
scrollbar-gutter: stable;
@@ -25,39 +14,12 @@
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
padding: 0.375rem 1rem;
font-size: $inner-section-text-size;
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
&.active {
background-color: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
font-weight: 600;
color: $blue-500;
}
}
.presetName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presetActions {
margin-left: auto;
}
.current {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
color: $blue-400;
font-size: $aux-text-size;
font-weight: 600;
}
@@ -4,7 +4,6 @@ import { useSearchParams } from 'react-router';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import Eyebrow from '../eyebrow/Eyebrow';
import style from './ViewParamsPresets.module.scss';
@@ -27,31 +26,22 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
return (
<div className={style.presetSection}>
<div className={style.header}>
<Eyebrow>Saved presets</Eyebrow>
</div>
<div className={style.presetList}>
{viewPresets.map((preset) => {
const active = searchParams.get('alias') === preset.alias;
return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
<span className={style.presetName}>{preset.alias}</span>
{active ? (
<span className={style.current}>Current</span>
) : (
<div>{preset.alias}</div>
<Button
variant='ghosted'
size='small'
variant={active ? 'ghosted' : 'subtle-white'}
onClick={() => handleRecall(preset)}
disabled={active}
className={style.presetActions}
>
Apply
{active ? 'Applied' : 'Apply'}
</Button>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -1,118 +1,48 @@
.section {
overflow: hidden;
color: $ui-white;
font-size: 1rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem;
background: transparent;
border: 0;
font: inherit;
text-align: left;
color: $gray-300;
height: 2rem;
&.collapsible {
cursor: pointer;
transition-property: color, background-color;
transition-duration: $transition-time-action;
&:hover {
background-color: $white-3;
color: $ui-white;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
}
}
.sectionTitle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/** marks a section which holds values coming from the URL */
.customised {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background-color: $active-indicator;
flex-shrink: 0;
}
.chevron {
flex-shrink: 0;
rotate: 180deg;
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&.closed {
rotate: 0deg;
}
}
.options {
padding-inline: 1rem;
border-top: 1px solid $white-7;
}
.label {
display: grid;
grid-template-columns: minmax(0, 1fr);
row-gap: 0.25rem;
padding: 0.75rem 0;
margin-top: $section-spacing;
font-size: $inner-section-text-size;
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
}
/**
* Compact controls sit beside their label instead of below it.
* Placement is done with grid areas so that the markup stays flat.
*/
.inline {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
column-gap: 1rem;
> .title {
grid-area: 1 / 1;
}
> .description {
grid-area: 2 / 1;
}
> *:last-child {
grid-area: 1 / 2 / span 2;
}
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title {
color: $ui-white;
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
color: $gray-400;
font-size: $aux-text-size;
line-height: 1.4;
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
}
.closed {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 0deg;
}
.open {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg;
}
.hidden {
@@ -1,9 +1,7 @@
import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { cx } from '../../utils/styleUtils';
import Eyebrow from '../eyebrow/Eyebrow';
import { OptionTitle } from './constants';
import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types';
@@ -16,74 +14,50 @@ interface ViewParamsSectionProps {
options: ParamField[];
}
/** Fields which are compact enough to share a row with their label */
function isInlineField(option: ParamField): boolean {
return option.type === 'boolean' || option.type === 'colour';
}
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const [searchParams] = useSearchParams();
// hidden options are inputs without a UI, there is nothing to frame them with
if (title === OptionTitle.Hidden) {
return <HiddenContents options={options} />;
const handleCollapse = () => {
if (collapsible) {
setCollapsed((prev) => !prev);
}
const isCollapsed = Boolean(collapsible && collapsed);
};
return (
<section className={style.section}>
<SectionHeader
title={title}
isCustomised={options.some((option) => searchParams.has(option.id))}
collapsed={isCollapsed}
onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
/>
{/* collapsed options stay mounted: the form reads its values from the DOM */}
<div className={cx([style.options, isCollapsed && style.hidden])}>
{options.map((option) => (
<label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
))}
{title === OptionTitle.Hidden ? (
<HiddenContents options={options} />
) : (
<>
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
{title}
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
</div>
<SectionContents options={options} collapsed={collapsed} />
</>
)}
</section>
);
}
interface SectionHeaderProps {
title: string;
isCustomised: boolean;
interface SectionContentsProps {
options: ParamField[];
collapsed: boolean;
/** a header without a toggle belongs to a section which cannot collapse */
onToggle?: () => void;
}
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) {
const label = (
<Eyebrow className={style.sectionTitle}>
{title}
{isCustomised && <span className={style.customised} title='Contains custom values' />}
</Eyebrow>
);
if (!onToggle) {
return <div className={style.sectionHeader}>{label}</div>;
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
return (
<button
type='button'
className={cx([style.sectionHeader, style.collapsible])}
aria-expanded={!collapsed}
onClick={onToggle}
>
{label}
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} />
</button>
<>
{options.map((option) => {
return (
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
);
})}
</>
);
}
@@ -1,13 +0,0 @@
import { TimerLifeCycle } from 'ontime-types';
import { getLifecycleLabel } from '../timerLifecycle';
describe('getLifecycleLabel', () => {
it('returns the shared label for known lifecycle values', () => {
expect(getLifecycleLabel(TimerLifeCycle.onClock)).toBe('Every second');
});
it('keeps unknown lifecycle values visible', () => {
expect(getLifecycleLabel('future-lifecycle')).toBe('future-lifecycle');
});
});
@@ -1,17 +0,0 @@
import { TimerLifeCycle } from 'ontime-types';
export const lifecycleLabels: Record<TimerLifeCycle, string> = {
[TimerLifeCycle.onLoad]: 'On Load',
[TimerLifeCycle.onStart]: 'On Start',
[TimerLifeCycle.onPause]: 'On Pause',
[TimerLifeCycle.onStop]: 'On Stop',
[TimerLifeCycle.onClock]: 'Every second',
[TimerLifeCycle.onUpdate]: 'On Timer Update',
[TimerLifeCycle.onFinish]: 'On Finish',
[TimerLifeCycle.onWarning]: 'On Warning',
[TimerLifeCycle.onDanger]: 'On Danger',
};
export function getLifecycleLabel(cycle: TimerLifeCycle | string): string {
return lifecycleLabels[cycle as TimerLifeCycle] ?? cycle;
}
@@ -1,24 +0,0 @@
import { PropsWithChildren } from 'react';
import { useScopedEntryActions } from '../hooks/useEntryAction';
import { EntryActionsProvider } from './EntryActionsContext';
import { RundownScopeProvider, useRundownScope, type RundownScopeProviderProps } from './RundownScopeContext';
/**
* Rundown scope for subtrees which mutate entries.
* Actions are bound to the same rundown as the data, so the two cannot disagree.
*/
export function EditableRundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
return (
<RundownScopeProvider rundownId={rundownId}>
<ScopedEntryActions>{children}</ScopedEntryActions>
</RundownScopeProvider>
);
}
function ScopedEntryActions({ children }: PropsWithChildren) {
const { rundownId } = useRundownScope();
const actions = useScopedEntryActions(rundownId);
return <EntryActionsProvider actions={actions}>{children}</EntryActionsProvider>;
}
@@ -1,77 +0,0 @@
import { MaybeString, Rundown } from 'ontime-types';
import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { getRundownCacheKey } from '../api/constants';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
import { ontimeQueryClient } from '../queryClient';
import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore';
export type RundownScopeValue = {
/** the rundown this subtree operates on */
rundownId: string;
/** whether this scope targets the rundown the runtime is playing */
isLoaded: boolean;
/** selection and cursor, scoped to this rundown */
selectionStore: EventSelectionStoreApi;
};
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export interface RundownScopeProviderProps extends PropsWithChildren {
/** rundown to operate on, null follows the loaded rundown */
rundownId: MaybeString;
}
/**
* Declares which rundown a subtree reads from.
*
* Data hooks resolve their rundown from here, so components never need to know
* which rundown they operate on. Nest a provider to point part of the tree at a
* different rundown; the app mounts one at the root that follows the loaded rundown.
*/
export function RundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
const {
data: { loaded },
} = useProjectRundowns();
const targetId = rundownId ?? loaded;
// the store reads the rundown lazily, the ref keeps it pointing at the current target
const targetIdRef = useRef(targetId);
const selectionStoreRef = useRef<EventSelectionStoreApi | null>(null);
if (selectionStoreRef.current === null) {
selectionStoreRef.current = createEventSelectionStore(() =>
ontimeQueryClient.getQueryData<Rundown>(getRundownCacheKey(targetIdRef.current)),
);
}
const selectionStore = selectionStoreRef.current;
// a selection refers to entries of a single rundown, it cannot survive a change of target
useEffect(() => {
targetIdRef.current = targetId;
selectionStore.getState().clearSelectedEvents();
}, [selectionStore, targetId]);
const value = useMemo(
(): RundownScopeValue => ({
rundownId: targetId,
// an unresolved target is not the loaded rundown, it is not yet any rundown
isLoaded: Boolean(loaded) && targetId === loaded,
selectionStore,
}),
[targetId, loaded, selectionStore],
);
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
}
export function useRundownScope(): RundownScopeValue {
const context = useContext(RundownScopeContext);
if (!context) {
throw new Error('useRundownScope must be used within a RundownScopeProvider');
}
return context;
}
@@ -1,81 +0,0 @@
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { flattenRundown, useRundownById } from './useRundownById';
/**
* Rundown data for surfaces which only ever work against the rundown being played:
* the viewers, the operator, app settings and the runtime overview.
*
* These take no part in a rundown scope, they resolve the loaded rundown directly.
* Anything which can be pointed at a background rundown reads from its scope instead.
*/
export function useLoadedRundown() {
const {
data: { loaded },
} = useProjectRundowns();
return useRundownById(loaded);
}
export function useLoadedRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data, status, rundownMetadata };
}
export function useLoadedFlatRundown() {
const { data, status } = useLoadedRundown();
const flatRundown = useMemo(() => flattenRundown(data), [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useLoadedFlatRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data: rundownWithMetadata, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*/
export function useLoadedPartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useLoadedFlatRundownWithMetadata();
const filteredData = useMemo(() => data.filter(cb), [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the loaded rundown.
* Runtime ids (the playing event, its group, the next flag) only exist here.
*/
export function useLoadedEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useLoadedRundown();
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useLoadedRundownAuxData() {
const { data, status } = useLoadedRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
@@ -1,34 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import type { ReportData } from 'ontime-types';
import { OntimeReport } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT } from '../api/constants';
import { fetchReport } from '../api/report';
const emptyReport: ReportData = {
eventReports: {},
rundown: null,
show: {
plannedStart: null,
plannedEnd: null,
plannedDuration: null,
actualStart: null,
actualEnd: null,
actualDuration: null,
},
};
export default function useReport() {
const { data: report, refetch } = useQuery<ReportData>({
const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT,
queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData) => previousData,
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
return {
data: report?.eventReports ?? emptyReport.eventReports,
report: report ?? emptyReport,
refetch,
};
return { data: data ?? {}, refetch };
}
+100 -31
View File
@@ -1,38 +1,62 @@
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { useRundownScope } from '../context/RundownScopeContext';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { flattenRundown, useRundownById } from './useRundownById';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/**
* Normalised rundown data for the rundown of the enclosing scope
* Normalised rundown data for the currently loaded rundown.
*
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/
export default function useRundown() {
const { rundownId } = useRundownScope();
return useRundownById(rundownId);
}
const queryClient = useQueryClient();
const {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
/**
* Runtime state only describes the loaded rundown,
* a scope pointed elsewhere must not show a playing event
*/
export function useScopedSelectedEventId(): EntryId | null {
const { isLoaded } = useRundownScope();
const selectedEventId = useSelectedEventId();
return isLoaded ? selectedEventId : null;
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, loadedRundownId, queryClient]);
// Once we have the ID, drop the temporary current cache
useEffect(() => {
if (!loadedRundownId) return;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useScopedSelectedEventId();
// key on the fields the derivation reads, a revision only change must not churn the list
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
@@ -42,23 +66,41 @@ export function useRundownWithMetadata() {
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const flatRundown = useMemo(() => flattenRundown(data), [data]);
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useScopedSelectedEventId();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the rundown
*/
@@ -68,3 +110,30 @@ export function useEntry(entryId: EntryId | null): OntimeEntry | null {
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useRundownAuxData() {
const { data, status } = useRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -1,65 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useRef } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/**
* Provides access to a specific rundown by ID.
*
* Without an ID we do not yet know which rundown is loaded, so we bootstrap via
* the `/current` alias to keep the first paint to a single round-trip, then seed
* the id-keyed cache that every other reader shares.
*/
export function useRundownById(rundownId: string | null | undefined) {
const queryClient = useQueryClient();
const id = rundownId ?? '';
const isBootstrap = id === '';
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownCacheKey(id),
queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })),
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || !isBootstrap) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, isBootstrap, queryClient]);
// Once we have the ID, drop the temporary current cache.
// Only the reader which bootstrapped may do so, others are still relying on it.
const didBootstrap = useRef(isBootstrap);
useEffect(() => {
if (isBootstrap || !didBootstrap.current) return;
didBootstrap.current = false;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [isBootstrap, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
/**
* Builds a flat rundown from the order and entries fields
*/
export function flattenRundown(rundown: Rundown): OntimeEntry[] {
if (rundown.revision === -1) {
return [];
}
return rundown.flatOrder
.map((id) => rundown.entries[id])
.filter((entry): entry is OntimeEntry => entry !== undefined);
}
@@ -0,0 +1,54 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { SessionStats } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { SESSION_STATS } from '../api/constants';
import { getSessionStats } from '../api/session';
export default function useSessionStats() {
const { data, status, isError, refetch } = useQuery<SessionStats>({
queryKey: SESSION_STATS,
queryFn: ({ signal }) => getSessionStats({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchInterval,
});
return { data, status, isError, refetch };
}
@@ -1,34 +1,26 @@
import { EntryId, MaybeNumber, Playback } from 'ontime-types';
import { MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { useIsOnline, usePlayback } from './useSocket';
import { usePlayback } from './useSocket';
/**
* Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber, eventId?: EntryId | null): number {
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
const playback = usePlayback();
const isOnline = useIsOnline();
const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, duration, eventId, playback, at: performance.now() });
const baseline = useRef({ current, at: performance.now() });
const [, setTick] = useState(0);
const now = performance.now();
const hasAuthoritativeUpdate =
baseline.current.current !== current || // handle timer updates
baseline.current.duration !== duration || // handle duration changes
baseline.current.eventId !== eventId || // handle event changing
baseline.current.playback !== playback; // handle playback changes
// there is only something to animate while a running timer is counting down towards 0
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
if (hasAuthoritativeUpdate) {
// Reset during render so an event change is reflected in this very paint.
baseline.current = { current, duration, eventId, playback, at: now };
}
// There is only something to animate while a connected timer is counting down towards 0.
const shouldAnimate = isOnline && isRunning && current !== null && current > 0 && duration !== null;
// re-anchor to the authoritative value whenever the server pushes a new timer update
useEffect(() => {
baseline.current = { current, at: performance.now() };
}, [current, duration, playback]);
// while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => {
@@ -42,8 +34,8 @@ export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber,
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// Derive from the anchor plus elapsed time at render; freeze while disconnected or not running.
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
const anchored = baseline.current.current;
const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored;
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
return getProgress(value, duration);
}
+19 -71
View File
@@ -8,6 +8,7 @@ import {
OntimeGroup,
OntimeMilestone,
PatchWithId,
ProjectRundownsList,
Rundown,
SupportedEntry,
TimeField,
@@ -31,10 +32,9 @@ import {
swapEventData,
} from 'ontime-utils';
import { useCallback, useMemo } from 'react';
import isEqual from 'react-fast-compare';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { getRundownCacheKey } from '../api/constants';
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants';
import {
ReorderEntry,
deleteEntries,
@@ -49,10 +49,8 @@ import {
requestEventSwap,
requestGroupEntries,
requestUngroup,
requestFitGroupTarget,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useRundownScope } from '../context/RundownScopeContext';
import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
@@ -65,38 +63,22 @@ export type EventOptions = Partial<{
lastEventId: MaybeString;
}>;
/**
* Applies a patch the way the server does, revision included.
*
* An entry revision advances on every change to that entry, which makes it a
* cheap marker for whether an entry has moved on. The optimistic entry has to
* carry the revision the server will return, otherwise the two disagree and the
* refetch resolves to a different object for no reason.
* Mirrors applyPatchToEntry in the rundown service: delays carry no revision.
*/
function patchEntry(entry: OntimeEntry, patch: Partial<OntimeEntry>): OntimeEntry {
if (isOntimeEvent(entry) || isOntimeGroup(entry) || isOntimeMilestone(entry)) {
return { ...entry, ...patch, revision: entry.revision + 1 } as OntimeEntry;
}
return { ...entry, ...patch } as OntimeEntry;
}
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/**
* Gather utilities for actions on entries in the rundown of the enclosing scope.
* Gather utilities for actions on entries in the loaded rundown.
*/
export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId);
export const useEntryActions = () => useEntryActionsForRundown(undefined);
/**
* Gather utilities for actions on entries in an explicitly selected rundown.
*/
export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? '');
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string) {
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
const queryClient = useQueryClient();
const {
linkPrevious,
@@ -110,8 +92,12 @@ function useEntryActionsForRundown(scopedRundownId: string) {
} = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => {
return getRundownCacheKey(scopedRundownId);
}, [scopedRundownId]);
if (scopedRundownId !== undefined) {
return getRundownQueryKey(scopedRundownId);
}
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
/**
* Returns the currently loaded rundown
@@ -338,10 +324,8 @@ function useEntryActionsForRundown(scopedRundownId: string) {
if (previousData && eventId) {
// optimistically update object
const newRundown = { ...previousData.entries };
const previousEntry = newRundown[eventId];
if (previousEntry) {
newRundown[eventId] = patchEntry(previousEntry, newEvent);
}
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData<Rundown>(queryKey, {
id: previousData.id,
title: previousData.title,
@@ -355,23 +339,6 @@ function useEntryActionsForRundown(scopedRundownId: string) {
// Return a context with the previous and new events
return { previousData, newEvent, queryKey };
},
// the server is the authority on the applied patch, it may normalise what we sent
onSuccess: (response, _variables, context) => {
const serverEntry = response.data;
if (!serverEntry || !context?.queryKey) return;
const cachedRundown = queryClient.getQueryData<Rundown>(context.queryKey);
if (!cachedRundown) return;
// our optimistic entry usually describes the change exactly, writing an
// identical entry would discard the cached reference for nothing
if (isEqual(cachedRundown.entries[serverEntry.id], serverEntry)) return;
queryClient.setQueryData<Rundown>(context.queryKey, {
...cachedRundown,
entries: { ...cachedRundown.entries, [serverEntry.id]: serverEntry },
});
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
if (context?.previousData && context?.queryKey) {
@@ -499,27 +466,7 @@ function useEntryActionsForRundown(scopedRundownId: string) {
return previousEnd;
}
},
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey],
);
/**
* Updates time of existing event so it satisfies the group target duration
* @param eventId {EntryId} - id of the event
*/
const matchGroupDuration = useCallback(
async (eventId: EntryId) => {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
try {
await requestFitGroupTarget(rundownId, eventId);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[getCurrentRundownData],
[getCurrentRundownData, updateEntryMutation, queryClient],
);
/**
@@ -544,7 +491,10 @@ function useEntryActionsForRundown(scopedRundownId: string) {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = patchEntry(event, data.data);
newRundown[eventId] = {
...event,
...data,
};
}
}
});
@@ -1059,7 +1009,6 @@ function useEntryActionsForRundown(scopedRundownId: string) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
}),
[
addEntry,
@@ -1077,7 +1026,6 @@ function useEntryActionsForRundown(scopedRundownId: string) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
],
);
}
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef | null>,
scrollRef: RefObject<ScrollRef | null>,
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
@@ -21,16 +21,18 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
followTrigger: MaybeString; // this would be an entry id or null
getTopOffset: () => number;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
}
export default function useFollowComponent({
followRef,
scrollRef,
doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
getTopOffset,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
useEffect(() => {
@@ -39,17 +41,25 @@ export default function useFollowComponent({
}
if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
// resolve the offset after layout, so that measured values are up to date
scrollToComponent(followRef, scrollRef, getTopOffset());
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
setScrollFlag?.(false);
});
}
}, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]);
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback(() => {
scrollToComponent(followRef, scrollRef, getTopOffset());
}, [followRef, scrollRef, getTopOffset]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent;
}
@@ -163,7 +163,6 @@ export const useNextFlag = createSelector((state: RuntimeStore) => ({
export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current,
duration: state.timer.duration,
eventId: state.eventNow?.id ?? null,
timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null,
}));
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
*/
export function useWindowTitle(title: string) {
useEffect(() => {
document.title = `Ontime - ${title}`;
document.title = `ontime - ${title}`;
}, []);
}
@@ -1,64 +0,0 @@
import { Rundown, SupportedEntry } from 'ontime-types';
import { createEventSelectionStore } from '../eventSelectionStore';
function makeRundown(id: string, eventIds: string[]): Rundown {
return {
id,
title: id,
order: eventIds,
flatOrder: eventIds,
entries: Object.fromEntries(eventIds.map((entryId) => [entryId, { id: entryId, type: SupportedEntry.Event }])),
revision: 1,
} as unknown as Rundown;
}
describe('createEventSelectionStore', () => {
it('keeps selections of separate instances independent', () => {
const first = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
const second = createEventSelectionStore(() => makeRundown('rundown-b', ['b1', 'b2']));
first.getState().setSingleEntrySelection({ id: 'a1' });
second.getState().setSingleEntrySelection({ id: 'b2' });
expect(first.getState().cursor).toBe('a1');
expect(second.getState().cursor).toBe('b2');
expect(first.getState().selectedEvents).toEqual(new Set(['a1']));
expect(second.getState().selectedEvents).toEqual(new Set(['b2']));
});
it('resolves a shift range against the injected rundown', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2', 'a3']));
store.getState().setSelectedEvents({ id: 'a3', index: 2, selectMode: 'shift' });
// without an anchor the range runs from the top up to the clicked index
expect(store.getState().selectedEvents).toEqual(new Set(['a1', 'a2']));
expect(store.getState().anchoredIndex).toBe(2);
});
it('replaces the selection set so subscribers on the set re-render', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'click' });
const afterClick = store.getState().selectedEvents;
store.getState().setSelectedEvents({ id: 'a2', index: 1, selectMode: 'ctrl' });
const afterAdd = store.getState().selectedEvents;
expect(afterAdd).not.toBe(afterClick);
expect(afterAdd).toEqual(new Set(['a1', 'a2']));
store.getState().unselect('a1');
const afterUnselect = store.getState().selectedEvents;
expect(afterUnselect).not.toBe(afterAdd);
expect(afterUnselect).toEqual(new Set(['a2']));
});
it('does not select when the rundown is unavailable', () => {
const store = createEventSelectionStore(() => undefined);
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'shift' });
expect(store.getState().selectedEvents).toEqual(new Set());
});
});
@@ -1,21 +1,14 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: MaybeString;
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
entryCopyRundownId: MaybeString;
entryCopyId: string | null;
entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: MaybeString, rundownId: MaybeString, mode?: 'copy' | 'cut') => void;
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
};
/**
* The clipboard is shared across rundowns so entries can be moved between them
*/
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
entryCopyRundownId: null,
entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }),
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
}));
@@ -1,158 +0,0 @@
import { EntryId, MaybeNumber, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { StoreApi } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { isMacOS } from '../utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
export interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
export type EventSelectionStoreApi = StoreApi<EventSelectionStore>;
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
*
* One store instance exists per rundown scope, so panels showing different
* rundowns keep independent selections. The rundown the selection refers to is
* injected as `getRundown` rather than resolved from the loaded rundown.
*/
export function createEventSelectionStore(getRundown: () => Rundown | undefined): EventSelectionStoreApi {
return createStore<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getRundown();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: new Set(selectedEvents).add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
const withoutId = new Set(selectedEvents);
withoutId.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && withoutId.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents: withoutId,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getRundown();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
const remaining = new Set(selectedEvents);
remaining.delete(id);
set({
selectedEvents: remaining,
entryMode: remaining.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
}
+4 -1
View File
@@ -15,11 +15,14 @@ const logger = createStore<LogStore>(() => ({
logs: [],
}));
/** the log is kept in memory, we cap it to avoid it growing through a long show */
const maxLogEntries = 500;
export const useLogData = () => useStore(logger);
export const addLog = (log: Log) =>
logger.setState((state) => ({
logs: [log, ...state.logs],
logs: [log, ...state.logs].slice(0, maxLogEntries),
}));
export const clearLogs = () => logger.setState({ logs: [] });
@@ -1,32 +0,0 @@
import type { AutomationOutput } from 'ontime-types';
import { summariseOutputs } from '../automationOutputs';
describe('summariseOutputs', () => {
it('returns an empty list when there are no outputs', () => {
expect(summariseOutputs([])).toEqual([]);
});
it('counts repeated output kinds', () => {
const outputs: AutomationOutput[] = [
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/stop', args: '' },
{ type: 'http', url: 'http://127.0.0.1/start' },
];
expect(summariseOutputs(outputs)).toEqual([
{ type: 'osc', label: 'OSC', count: 2 },
{ type: 'http', label: 'HTTP', count: 1 },
]);
});
it('presents kinds in a stable order regardless of insertion order', () => {
const outputs: AutomationOutput[] = [
{ type: 'ontime', action: 'aux1-start' },
{ type: 'http', url: 'http://127.0.0.1/start' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
];
expect(summariseOutputs(outputs).map(({ type }) => type)).toEqual(['osc', 'http', 'ontime']);
});
});
@@ -1,23 +0,0 @@
import type { OntimeEventReport } from 'ontime-types';
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
import { getEventVariance } from '../report';
it('uses captured days when measuring an event across midnight', () => {
const report: OntimeEventReport = {
startedAt: dayInMs - 5 * MILLIS_PER_MINUTE,
startedAtDay: 0,
endedAt: 5 * MILLIS_PER_MINUTE,
endedAtDay: 1,
scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE,
scheduledDay: 0,
scheduledDuration: 10 * MILLIS_PER_MINUTE,
};
expect(getEventVariance(report)).toMatchObject({
actualDuration: 10 * MILLIS_PER_MINUTE,
delta: 0,
status: 'ontime',
});
expect(getEventVariance({ ...report, endedAt: null })).toMatchObject({ status: 'not-run' });
});
@@ -58,16 +58,4 @@ describe('formatDuration()', () => {
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
expect(formatDuration(599702, false)).toBe('9m59s');
});
it('formats durations differently with and without seconds', () => {
expect(formatDuration(0, false)).toBe('0m');
expect(formatDuration(0, true)).toBe('0m');
expect(formatDuration(30 * MILLIS_PER_SECOND, false)).toBe('30s');
expect(formatDuration(30 * MILLIS_PER_SECOND, true)).toBe('');
expect(formatDuration(2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_SECOND, false)).toBe('2h30s');
expect(formatDuration(2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_SECOND, true)).toBe('2h');
expect(formatDuration(2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 30 * MILLIS_PER_SECOND, false)).toBe(
'2h10m30s',
);
expect(formatDuration(2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 30 * MILLIS_PER_SECOND, true)).toBe('2h10m');
});
});
@@ -1,26 +0,0 @@
import type { AutomationOutput } from 'ontime-types';
const outputLabels: Record<AutomationOutput['type'], string> = {
osc: 'OSC',
http: 'HTTP',
ontime: 'Ontime',
};
export type OutputSummary = {
type: AutomationOutput['type'];
label: string;
count: number;
};
export function summariseOutputs(outputs: AutomationOutput[]): OutputSummary[] {
const counts = new Map<AutomationOutput['type'], number>();
for (const output of outputs) {
counts.set(output.type, (counts.get(output.type) ?? 0) + 1);
}
const order: AutomationOutput['type'][] = ['osc', 'http', 'ontime'];
return order
.filter((type) => counts.has(type))
.map((type) => ({ type, label: outputLabels[type], count: counts.get(type) as number }));
}
-4
View File
@@ -4,10 +4,6 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Enter';
}
export function isKeySpace<T>(event: KeyboardEvent<T>): boolean {
return event.key === ' ' || event.code === 'Space';
}
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape';
}
-29
View File
@@ -1,29 +0,0 @@
import type { MaybeNumber, OntimeEventReport } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND } from 'ontime-utils';
type EventVariance = {
actualDuration: MaybeNumber;
delta: number;
status: 'ontime' | 'over' | 'under' | 'not-run';
};
const notRun: EventVariance = { actualDuration: null, delta: 0, status: 'not-run' };
export function getReportTimePosition(time: number, day: number): number;
export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber;
export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber {
return time === null || day === null ? null : day * dayInMs + time;
}
export function getEventVariance(entry: OntimeEventReport | undefined): EventVariance {
if (!entry) return notRun;
const start = getReportTimePosition(entry.startedAt, entry.startedAtDay);
const end = getReportTimePosition(entry.endedAt, entry.endedAtDay);
if (start === null || end === null) return notRun;
const actualDuration = end - start;
const delta = actualDuration - entry.scheduledDuration;
if (Math.abs(delta) < MILLIS_PER_SECOND) return { actualDuration, delta, status: 'ontime' };
return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' };
}
+3 -3
View File
@@ -242,12 +242,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
return;
}
// skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
// we already have this change, or something newer
// messages can arrive after a refetch has already brought in a later revision
if (revision !== null && cachedRundown !== undefined && revision <= cachedRundown.revision) {
if (revision === cachedRundown?.revision) {
// we already have the latest change
return;
}
+35
View File
@@ -1,3 +1,5 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' {
const content: Record<string, string>;
export default content;
@@ -30,6 +32,39 @@ declare global {
}
}
/**
* Declare custom data we pass to the table
* - `handleUpdate` callback to update the entry when the user edits a cell
* - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table
* - `options-hideIndexColumn` whether to hide the index column
* - `options-cuesheetMode` run or edit mode
*
* And metadata specific for each column
* - `canWrite` whether the user can write to this column
* - `colour` background colour associated with a custom field
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
hideIndexColumn: boolean;
cuesheetMode: AppMode;
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
canWrite: boolean;
colour?: string;
}
}
/**
* Allow passing CSS Properties
*/
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest';
import { matchesSettingsOptionQuery } from '../useAppSettingsMenu';
describe('matchesSettingsOptionQuery', () => {
const option = {
id: 'settings__general',
label: 'General settings',
keywords: ['pin', 'time zone'],
};
it('does not match an empty or whitespace-only query', () => {
expect(matchesSettingsOptionQuery(option, '')).toBe(false);
expect(matchesSettingsOptionQuery(option, ' ')).toBe(false);
});
it('matches keywords case-insensitively', () => {
expect(matchesSettingsOptionQuery(option, 'PIN')).toBe(true);
expect(matchesSettingsOptionQuery(option, 'TIME ZONE')).toBe(true);
});
});
@@ -1,4 +1,9 @@
$content-max-width: 1280px;
.corner {
position: fixed;
top: 6rem;
right: 4rem;
z-index: $zindex-floating;
}
.contentWrapper {
display: flex;
@@ -9,30 +14,9 @@ $content-max-width: 1280px;
position: relative;
}
.corner {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
// width is needed so the button can align to the right of the capped
// column, auto margins alone would shrink this to fit and centre it
width: 100%;
max-width: $content-max-width;
margin-inline: auto;
padding: 0 1rem 0.5rem;
box-sizing: border-box;
}
.content {
box-sizing: border-box;
// as with .corner, the width is needed to fill the capped column, otherwise
// auto margins shrink this to fit and each panel ends up its own width
width: 100%;
max-width: $content-max-width;
margin: 0 auto 1rem;
padding-inline: 1rem;
// Own both scroll axes so sticky table headers stay anchored to the panel viewport.
overflow: auto;
margin: 1rem;
overflow-y: auto;
flex-grow: 1;
// room for the last section to scroll to the top of the viewport
padding-bottom: 40vh;
padding-bottom: 300px;
}
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return (
<div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}>
<Button size='large' onClick={onClose}>
Close settings <IoClose />
</Button>
</div>
<div className={style.content}>{children}</div>
</div>
);
}
@@ -1,13 +1,3 @@
.container {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
}
.tabs,
ul {
list-style: none;
@@ -16,20 +6,22 @@ ul {
}
.tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.primary,
.secondary {
padding: 0.25rem 1rem;
margin-right: 1rem;
border-radius: 2px;
&:focus-visible {
&:focus {
background-color: $gray-1000;
outline: 2px solid $blue-500;
outline-offset: -2px;
outline: 0;
}
&:hover {
@@ -40,21 +32,14 @@ ul {
.primary {
font-size: 1rem;
border-radius: 2px;
display: flex;
align-items: center;
gap: 0.5rem;
border-left: 3px solid transparent;
&.groupActive {
color: $blue-400;
font-weight: 600;
}
&.active {
color: $ui-white;
border-left-color: $blue-400;
background-color: $bg-active;
font-weight: 600;
color: $blue-400;
background-color: $gray-1100;
}
&.highlight {
@@ -77,9 +62,6 @@ ul {
font-size: $inner-section-text-size;
&.active {
color: $ui-white;
border-left-color: $blue-400;
background-color: $bg-active;
font-weight: 600;
color: $blue-400;
}
}
@@ -1,18 +1,10 @@
import { Fragment, useState } from 'react';
import { Fragment } from 'react';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import * as Panel from '../panel-utils/PanelUtils';
import {
filterSettingsOptions,
matchesSettingsOptionQuery,
SettingsOption,
SettingsOptionId,
useAppSettingsMenu,
} from '../useAppSettingsMenu';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
import SettingsSearch from './SettingsSearch';
import style from './PanelList.module.scss';
@@ -24,44 +16,12 @@ interface PanelListProps extends PanelBaseProps {
selectedPanel: string;
}
/** Returns the first matching setting, preferring a matching child over its non-matching group. */
function getFirstResultId(results: SettingsOption[], query: string): SettingsOptionId | null {
const firstResult = results[0];
if (!firstResult) {
return null;
}
if (matchesSettingsOptionQuery(firstResult, query.trim().toLowerCase())) {
return firstResult.id as SettingsOptionId;
}
return (firstResult.secondary?.[0]?.id as SettingsOptionId | undefined) ?? null;
}
export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu();
const { setLocation } = useAppSettingsNavigation();
const [query, setQuery] = useState('');
const results = filterSettingsOptions(options, query);
const handleSearchSubmit = () => {
const target = getFirstResultId(results, query);
if (target) {
setLocation(target);
setQuery('');
}
};
return (
<div className={style.container}>
<SettingsSearch query={query} onQueryChange={setQuery} onSubmit={handleSearchSubmit} />
{results.length === 0 ? (
<Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} />
) : (
<ul className={style.tabs}>
{results.map((panel) => {
{options.map((panel) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
@@ -73,8 +33,6 @@ export default function PanelList({ selectedPanel, location }: PanelListProps) {
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
)}
</div>
);
}
@@ -86,15 +44,7 @@ interface PanelListItemProps {
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
const { setLocation } = useAppSettingsNavigation();
const hasSelectedChild = Boolean(
isSelected && panel.secondary?.some((secondary) => secondary.id.split('__')[1] === location),
);
const classes = cx([
style.primary,
isSelected && !hasSelectedChild && style.active,
hasSelectedChild && style.groupActive,
panel.highlight && style.highlight,
]);
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
return (
<Fragment key={panel.id}>
@@ -125,7 +75,6 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
}
}}
className={secondaryClasses}
tabIndex={0}
role='button'
>
{secondary.label}
@@ -1,24 +0,0 @@
.search {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
margin: 0.25rem 1rem 0.25rem 0.25rem;
}
.icon {
position: absolute;
left: 0.5rem;
color: $gray-400;
pointer-events: none;
}
.input {
padding-left: 2rem;
padding-right: 2rem;
}
.clear {
position: absolute;
right: 0.25rem;
}
@@ -1,60 +0,0 @@
import { KeyboardEvent, useRef } from 'react';
import { IoClose, IoSearch } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Input from '../../../common/components/input/input/Input';
import style from './SettingsSearch.module.scss';
interface SettingsSearchProps {
query: string;
onQueryChange: (query: string) => void;
onSubmit: () => void;
}
export default function SettingsSearch({ query, onQueryChange, onSubmit }: SettingsSearchProps) {
const searchRef = useRef<HTMLInputElement>(null);
const isSearching = query.trim().length > 0;
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape' && isSearching) {
// Do not let the settings panel close while the user is clearing a search.
event.stopPropagation();
onQueryChange('');
return;
}
if (event.key === 'Enter') {
onSubmit();
}
};
return (
<div className={style.search}>
<IoSearch className={style.icon} />
<Input
ref={searchRef}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder='Search settings'
className={style.input}
fluid
/>
{isSearching && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.clear}
onClick={() => {
onQueryChange('');
searchRef.current?.focus();
}}
>
<IoClose />
</IconButton>
)}
</div>
);
}
@@ -1,26 +1,24 @@
$inner-padding: 1rem;
$card-padding: 2rem;
.header {
font-size: 1.5rem;
font-size: 2rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10;
font-weight: 600;
}
.subheader {
font-size: 1.125rem;
font-size: 1.5rem;
padding-bottom: 0.5rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.title {
font-size: 1rem;
padding: 0 var(--panel-card-padding, #{$card-padding});
font-size: 1.375rem;
padding: 0 2rem;
font-weight: 600;
display: flex;
align-items: center;
@@ -37,12 +35,10 @@ $card-padding: 2rem;
color: $ui-white;
}
// width is owned by the panel content column, which caps and centres itself.
// sections must stay full width so top level cards line up with Panel.Header
// and nested sections fill their card rather than shrinking to fit
.section {
position: relative;
margin-top: 2rem;
max-width: 1024px;
}
.indent {
@@ -55,10 +51,8 @@ $card-padding: 2rem;
}
.card {
--panel-card-padding: #{$card-padding};
position: relative;
padding: var(--panel-card-padding);
padding: 2rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: 3px;
@@ -82,10 +76,10 @@ $card-padding: 2rem;
color: $error-red;
}
// tables scroll with the panel rather than owning a nested scroll area,
// which keeps the sticky table head anchored to the panel viewport
.pad {
padding: 0 var(--panel-card-padding, #{$card-padding});
padding: 0 2rem;
max-height: 550px;
overflow-y: scroll;
}
.table {
@@ -103,13 +97,10 @@ $card-padding: 2rem;
}
th {
font-weight: 600;
color: $gray-300;
font-size: calc(1rem - 3px);
letter-spacing: 0.02em;
vertical-align: bottom;
font-weight: 400;
color: $gray-400;
white-space: nowrap;
text-transform: uppercase;
text-transform: capitalize;
}
th,
@@ -121,26 +112,19 @@ $card-padding: 2rem;
background-color: $white-1;
}
// active rows use a panel-local class today; keep the treatment here so all
// settings tables share the same state without each panel owning its colours
tr[class*='current'] {
background-color: $bg-active;
> td:first-child {
box-shadow: inset 3px 0 $blue-400;
font-weight: 500;
}
}
// allow highlighting table elements
tr[data-warn='true'],
td[data-warn='true'] {
background-color: $orange-1300;
}
tr[data-highlight='true'] {
background-color: $blue-1100;
}
}
.listGroup {
padding: 0 var(--panel-card-padding, #{$card-padding});
padding: 0 2rem;
> li:not(:last-child) {
border-bottom: 1px solid $white-10;
@@ -153,17 +137,6 @@ $card-padding: 2rem;
align-items: center;
gap: 1rem;
padding: 0.5rem 0;
&.interactive {
margin-inline: calc(var(--panel-card-padding, #{$card-padding}) * -1);
padding: 0.75rem var(--panel-card-padding, #{$card-padding});
transition: background-color $transition-time-action;
&:hover,
&:focus-within {
background-color: $white-3;
}
}
}
.fieldTitle {
@@ -194,13 +167,14 @@ $card-padding: 2rem;
.divider {
border: none;
border-top: 1px solid $white-10;
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1);
margin: 1rem -2rem;
}
.overlay {
position: absolute;
z-index: $zindex-backdrop;
inset: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(2px);
display: grid;
place-content: center;
@@ -218,35 +192,19 @@ $card-padding: 2rem;
animation: animloader 1s ease-in infinite;
}
.emptyState {
padding: 3rem 1.5rem;
.empty {
background-color: $black-10;
text-align: center;
}
color: $muted-gray;
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
td {
padding-block: 5rem;
}
.emptyTitle {
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
font-size: calc(1rem + 2px);
font-weight: 400;
}
.emptyBody {
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
button {
margin-top: 1rem;
margin-inline: auto;
}
}
.inlineElements {
@@ -285,3 +243,14 @@ $card-padding: 2rem;
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,5 +1,7 @@
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { IoAdd } from 'react-icons/io5';
import Button from '../../../common/components/buttons/Button';
import { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss';
@@ -60,29 +62,16 @@ export function Table({ className, children }: { className?: string; children: R
);
}
export interface EmptyStateProps {
title: string;
description?: ReactNode;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
return (
<div className={style.emptyState}>
<div className={style.emptyMessage}>
<div className={style.emptyTitle}>{title}</div>
{description && <div className={style.emptyBody}>{description}</div>}
{action && <div className={style.emptyAction}>{action}</div>}
</div>
</div>
);
}
export function TableEmpty(props: EmptyStateProps) {
return (
<tr>
<tr className={style.empty}>
<td colSpan={99}>
<EmptyState {...props} />
<div>{label ?? 'No data yet'}</div>
{handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
</td>
</tr>
);
@@ -92,8 +81,8 @@ export function ListGroup({ className, children }: { className?: string; childre
return <ul className={cx([style.listGroup, className])}>{children}</ul>;
}
export function ListItem({ children, interactive = false }: { children: ReactNode; interactive?: boolean }) {
return <li className={cx([style.listItem, interactive && style.interactive])}>{children}</li>;
export function ListItem({ children }: { children: ReactNode }) {
return <li className={style.listItem}>{children}</li>;
}
export function Field({
@@ -1,4 +1,4 @@
import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import {
buyMeACoffeeUrl,
discordUrl,
@@ -11,8 +11,6 @@ import {
} from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion';
import CloudPanel from './CloudPanel';
import ExternalLinkRow from './ExternalLinkRow';
export default function AboutPanel() {
return (
@@ -21,49 +19,28 @@ export default function AboutPanel() {
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Divider />
<Info>Free, open-source software for managing rundowns and event timers.</Info>
<Panel.ListGroup>
<ExternalLinkRow href={websiteUrl} title='www.getontime.no' description='App site and downloads' />
<ExternalLinkRow
href={githubSponsorUrl}
title='GitHub Sponsors'
description='Support development through GitHub'
/>
<ExternalLinkRow
href={buyMeACoffeeUrl}
title='Buy Me a Coffee'
description='Make a one-time contribution'
/>
</Panel.ListGroup>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
Considering sponsoring our work
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
If you are looking for an online version of Ontime, consider trying out our cloud service
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Card>
</Panel.Section>
<CloudPanel />
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<AppVersion />
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Helpful links</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<ExternalLinkRow
href={documentationUrl}
title='Read the docs'
description='Setup guides and feature documentation'
/>
<ExternalLinkRow href={githubUrl} title='GitHub' description='Report issues or contribute to Ontime' />
<ExternalLinkRow href={youtubeUrl} title='YouTube' description='Watch tutorials and feature walkthroughs' />
<ExternalLinkRow href={discordUrl} title='Discord' description='Get help and share suggestions' />
<ExternalLinkRow href={subredditUrl} title='Reddit' description='Join the Ontime community discussion' />
</Panel.ListGroup>
</Panel.Card>
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section>
</>
);
@@ -1,7 +0,0 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}

Some files were not shown because too many files have changed in this diff Show More