Compare commits

..

21 Commits

Author SHA1 Message Date
Carlos Valente 9a6feed49b refactor: improve groups and time end 2026-06-28 09:08:24 +02:00
Carlos Valente c6cf9f2bf7 fixup! refactor: add to correct section 2026-06-28 08:48:14 +02:00
Carlos Valente 2f73303554 refactor: simplify object creation 2026-06-28 08:46:11 +02:00
Carlos Valente 96943ca528 refactor: add to correct section 2026-06-28 08:45:54 +02:00
Carlos Valente 8cfffd32c7 fix: duplicate rundowns from service layer 2026-06-27 16:30:04 +02:00
Carlos Valente 24a606a246 fixup! improve linting and documentation 2026-06-27 16:27:59 +02:00
Carlos Valente c322028821 docs 2026-06-27 16:27:59 +02:00
Carlos Valente 9f40d962ea improve linting and documentation 2026-06-27 16:27:59 +02:00
Claude 1e851ae40d refactor(mcp): port tools to current services with typed translation handlers
- Tool handlers are now a typed map of thin translation wrappers: wire
  arguments are cast once at the boundary to types derived (Pick) from
  ontime-types, and all service calls are fully typed — a change to a
  service signature or domain type now fails compilation in api-mcp
  instead of drifting silently. No more 'as never' casts.
- Entry mutations target the loaded rundown explicitly, matching the
  rundownId-first service signatures introduced with secondary rundowns.
- Rundown create/rename/delete/duplicate call the rundown service;
  mcp.service.ts and its duplicated router logic are deleted.
- ontime_create_entry supports all entry types (event, delay, milestone,
  group) and entry tools accept flag and custom field values, matching
  what the workflow prompts already instruct agents to do. Tool names
  use entry terminology consistently with the service layer.
- ontime_create_project description fixed: creating a project switches
  to it.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude 2bcda2dfa4 refactor(mcp): scope bearer authentication to the MCP route
Reverts the change to the shared authenticate middleware, which made
every authenticated route accept Authorization: Bearer tokens. Bearer
support now lives in a wrapper middleware inside api-mcp, mounted only
on /mcp; all other requests fall through to the app middleware
unchanged. Removing the api-mcp module restores stock auth behaviour.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude e2a2ea1d24 refactor(rundown): extract rundown rename/delete/duplicate into service layer
Moves the logic previously inlined in the router handlers into
rundown.service.ts so other consumers (such as the MCP server) can reuse
it without duplication. Status codes and error messages are unchanged.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude 5be596b83e refactor(mcp): dispatch map for resources, userPrompt helper, enrich prompt guidance
mcp.resources.ts: replace 6-branch if-chain with a RESOURCE_READERS dispatch map.
Each resource is now a one-liner { mimeType, read } entry; handleReadResource
collapses to a 3-line lookup with a single throw.

mcp.prompts.ts: extract repeated message-wrapping boilerplate into userPrompt()
helper, reducing each prompt from ~10 lines of structure to a single call.

Enrich all four prompts with operational domain knowledge:
- create_rundown_from_agenda: entry type selection (event/milestone/delay/group),
  timerType and endAction guidance, linkStart chain semantics, flag usage, colour
  conventions
- bulk_edit_rundown: timeStrategy (lock-end vs lock-duration), endAction automation
  risk, clarified time-shift mechanics
- validate_rundown: totalDelay/totalDays checks, linkStart chain validation,
  play-next chain disclosure, flag audit
- restructure_rundown: group awareness (entries vs order arrays), insert order type,
  move-out vs move-in patterns

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude ab15a65585 simplify: unify 405 handlers, fix double-read in renameRundown, guard mcpClientConfig
- mcp.router.ts: extract identical GET/DELETE 405 responses into methodNotAllowed
- mcp.service.ts: pass the already-patched rundown object to initRundown instead
  of re-fetching it from the data provider
- McpSection.tsx: compute mcpClientConfig only when mcpEndpointUrl is truthy

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 65ef442a9d refactor(mcp): consolidate documentation into mcp.schema.ts
Single file to update when the data model changes:
- EVENT_TIMER_FIELDS and EVENT_WRITABLE_FIELDS (tool JSON schemas) moved from mcp.tools.ts
- ONTIME_SCHEMA_MARKDOWN and ONTIME_DOCS_MARKDOWN moved from mcp.resources.ts
- mcp.tools.ts and mcp.resources.ts now import from mcp.schema.ts
- Removed inline data model notes from the agenda prompt; replaced with a reference
  to the ontime://schema resource
- Fixed inaccurate enum values in schema (Milestone type added, TimerType/EndAction
  corrected to match ontime-types)

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 71a7f26493 refactor(mcp): address review — split files, fix naming, stateless router
Split the monolithic mcp.router.ts into focused modules:
- mcp.service.ts: coordination helpers (rundownListResponse, renameRundown, deleteRundown)
- mcp.tools.ts: tool definitions (ontime_ prefix, annotation presets, shared field schemas,
  CHARACTER_LIMIT truncation) + handleToolCall dispatcher
- mcp.prompts.ts: prompt definitions + handleGetPrompt
- mcp.resources.ts: resource definitions (schema, live data, docs) + handleReadResource
- mcp.server.ts: createMcpServer factory that wires the above modules
- mcp.router.ts: thin stateless Express router (POST/GET405/DELETE405)

Other changes per review comments:
- Rename ontime_delete_event → ontime_delete_entry (entry vs event naming convention)
- Remove hardcoded cue/colour prefix conventions from tool descriptions and prompts;
  replace with "ask the user what convention they prefer"
- Remove automations tools (left for a later PR)
- Add Bearer auth comment in authenticate.ts explaining the MCP use case
- Extract McpSection component from FeaturePanel to isolate URL state re-renders
- Update bulk_edit prompt to mention linkStart cascade behaviour
- Add 405 comments explaining why GET/DELETE are not supported in stateless mode

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude b850a51204 mcp: add ontime://docs resource for documentation discoverability
Exposes a curated index of docs.getontime.no topic URLs as the
ontime://docs MCP resource, and adds a "Further reading" footer to
ontime://schema so agents reading the data model know where to find
deeper documentation.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 0094c15da2 feat(mcp): expose rundown and schema as readable MCP resources
Adds five resources so the agent can load context once per session
instead of re-fetching via tools, and so it can ground its answers in
Ontime's data model without guessing:

- ontime://schema — markdown reference for the event/rundown/group
  model, time format (ms from midnight), cue conventions, colours,
  playback states
- ontime://rundown/current — full currently-loaded rundown (JSON)
- ontime://rundowns — every rundown in the project plus the loaded ID
- ontime://project/info — project metadata
- ontime://project/custom-fields — custom field definitions

Declares the resources capability on the MCP server and registers
resources/list and resources/read handlers that dispatch by URI.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude e7d62f44e7 feat(mcp): batch rundown edits, project file management, workflow prompts
Adds 8 tools and 3 prompts to cover four priority pre-production flows:
build-from-agenda (batch create), bulk edit, validate, restructure.

Tools:
- create_events_batch, batch_update_events — avoid per-event round trips
- list_projects, load_project, create_project, rename_project,
  duplicate_project, delete_project — project file management

Prompts:
- bulk_edit_rundown — teaches the agent to use batch_update_events vs
  per-event updates based on whether fields are shared
- validate_rundown — pre-show checks for cue gaps, overlaps, missing
  fields, duration anomalies
- restructure_rundown — teaches reorder_event sequencing
- create_rundown_from_agenda — updated to use create_events_batch

Safety:
- Mutating tools now attach a playback warning to their response when
  timer state is not stopped, so the agent can relay it to the user
- create_automation description mentions the in-app test button

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 3c8d7e51f5 chore: ignore .claude/ metadata directory
https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 3da3950fce fix(mcp): session ID mismatch, rename_rundown data loss, DELETE status
- Session ID: deterministic UUID passed to sessionIdGenerator so the
  map key matches the mcp-session-id header sent to clients; previously
  two independent randomUUID() calls produced different IDs, breaking
  all post-initialize requests
- rename_rundown: use spread { ...rundown, title } instead of narrow
  structuredClone cast that silently stripped order/entries/revision
- DELETE /mcp: return 204 No Content instead of 200 with body

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude ac3694ce4d feat: add MCP server for AI agent integration
Adds a Streamable HTTP MCP server at /mcp that exposes 19 tools
covering rundown entries, rundown management, runtime state, project
info, automations, and custom fields. Includes Bearer token auth
support and a UI card in Settings > Sharing showing the endpoint URL
and Claude Desktop config.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
369 changed files with 6550 additions and 14401 deletions
-5
View File
@@ -1,5 +0,0 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
-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", "name": "@getontime/cli",
"version": "4.13.0", "version": "4.9.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+8 -9
View File
@@ -1,10 +1,10 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.13.0", "version": "4.9.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.7.0", "@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -13,12 +13,12 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^9.5.1", "@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.59.0", "@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^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", "@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.18.0", "axios": "^1.18.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-icons": "5.7.0", "react-icons": "5.6.0",
"react-router": "^8.0.1", "react-router": "^8.0.1",
"react-virtuoso": "^4.18.7", "react-virtuoso": "^4.18.7",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -60,8 +60,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "5.4.0", "@sentry/vite-plugin": "5.1.1",
"@types/node": "catalog:",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
@@ -73,8 +72,8 @@
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "8.2.1", "vite": "8.0.1",
"vite-plugin-compression2": "2.5.3", "vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0", "vite-plugin-svgr": "4.5.0",
"vitest": "catalog:" "vitest": "catalog:"
} }
+1 -4
View File
@@ -3,7 +3,6 @@ import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router'; import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { EditableRundownScopeProvider } from './common/context/EditableRundownScopeProvider';
import { PresetContext } from './common/context/PresetContext'; import { PresetContext } from './common/context/PresetContext';
import useUrlPresets from './common/hooks-query/useUrlPresets'; import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath'; import { useClientPath } from './common/hooks/useClientPath';
@@ -113,9 +112,7 @@ export default function AppRouter() {
path='rundown' path='rundown'
element={ element={
<EditorFeatureWrapper> <EditorFeatureWrapper>
<EditableRundownScopeProvider rundownId={null}> <RundownPanel />
<RundownPanel />
</EditableRundownScopeProvider>
</EditorFeatureWrapper> </EditorFeatureWrapper>
} }
/> />
Binary file not shown.
Binary file not shown.
Binary file not shown.
-8
View File
@@ -22,14 +22,6 @@ export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report']; export const REPORT = ['report'];
export const TRANSLATION = ['translation']; 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 // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
-17
View File
@@ -6,7 +6,6 @@ import {
ProjectRundownsList, ProjectRundownsList,
RenumberCues, RenumberCues,
Rundown, Rundown,
RundownImportPayload,
TransientEventPayload, TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
@@ -83,15 +82,6 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
return axios.delete(`${rundownPath}/${rundownId}`); return axios.delete(`${rundownPath}/${rundownId}`);
} }
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
}
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
// #region operations on rundown entries ========================== // #region operations on rundown entries ==========================
@@ -176,13 +166,6 @@ export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options); 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 * HTTP request for grouping a list of entries into a group
*/ */
@@ -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 { .textEntry {
grid-area: input;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex: 1;
min-width: 0;
color: $label-gray; color: $label-gray;
} }
.inlineEntry { .inlineEntry {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-areas:
align-items: center; 'label label'
gap: 0.75rem; 'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
} }
.redirect { .redirect {
margin-left: auto; grid-area: btn;
} }
.label { .label {
color: $gray-200; color: $label-gray;
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;
}
} }
@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5'; import { IoArrowForward } from 'react-icons/io5';
import { baseURI } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets'; import useUrlPresets from '../../hooks-query/useUrlPresets';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
@@ -9,7 +8,7 @@ import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog'; import Dialog from '../dialog/Dialog';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input'; 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 Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; import style from './RedirectClientModal.module.scss';
@@ -45,7 +44,6 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
}; };
const enabledPresets = data.filter((preset) => preset.enabled); const enabledPresets = data.filter((preset) => preset.enabled);
const settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`;
const viewOptions = [ const viewOptions = [
...navigatorConstants.map((view) => ({ ...navigatorConstants.map((view) => ({
@@ -68,46 +66,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
bodyElements={ bodyElements={
<> <>
<Info> <Info>
<Info.Body> Remotely redirect the client to a different URL. <br />
Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset. Either by entering a custom path or selecting a URL Preset.
</Info.Body> <br />
<Info.Footer> <br />
<ExternalLink href={settingsUrl}>Manage URL Presets</ExternalLink> <AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info.Footer>
</Info> </Info>
<section className={style.section}> <div className={style.inlineEntry}>
<div className={style.sectionHeader}> <span className={style.label}>Enter custom path</span>
<span className={style.label}>Enter custom path</span> <label className={style.textEntry}>
<span className={style.description}>Type the path the client should open.</span> {origin}
</div> <Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
<div className={style.inlineEntry}> </label>
<label className={style.textEntry}> <Button
<span className={style.origin}>{origin}</span> variant='primary'
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} /> aria-label='Redirect'
</label> disabled={path === currentPath || path === ''}
<Button className={style.redirect}
variant='primary' onClick={() => handleRedirect(path)}
aria-label='Redirect to custom path' >
disabled={path === currentPath || path === ''} Redirect
className={style.redirect} <IoArrowForward />
onClick={() => handleRedirect(path)} </Button>
>
Redirect
<IoArrowForward />
</Button>
</div>
</section>
<div className={style.separator} role='separator'>
<span>Or</span>
</div> </div>
<section className={style.section}> <div>
<div className={style.sectionHeader}> <span className={style.label}>Select View or URL Preset</span>
<span className={style.label}>Select view or URL Preset</span>
<span className={style.description}>Choose from the available destinations.</span>
</div>
<div className={style.inlineEntry}> <div className={style.inlineEntry}>
<label className={style.textEntry}> <label className={style.textEntry}>
<span className={style.origin}>{origin}</span> {origin}
<Select <Select
fluid fluid
options={viewOptions} options={viewOptions}
@@ -129,7 +115,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
Redirect <IoArrowForward /> Redirect <IoArrowForward />
</Button> </Button>
</div> </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;
}
@@ -5,8 +5,6 @@ import { setClientRemote } from '../../hooks/useSocket';
import Dialog from '../dialog/Dialog'; import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -33,22 +31,9 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
isOpen={isOpen} isOpen={isOpen}
title={`Rename Client: ${currentName}`} title={`Rename Client: ${currentName}`}
showCloseButton showCloseButton
showBackdrop
onClose={onClose} onClose={onClose}
bodyElements={ bodyElements={
<div className={style.form}> <Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
<label className={style.label}>
Client name
<Input
height='large'
fluid
autoComplete='off'
placeholder='New name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
</div>
} }
footerElements={ footerElements={
<> <>
@@ -1,16 +1,12 @@
.dialog { .dialog {
position: fixed; position: fixed;
top: 10vh; top: 10%;
left: 50%; left: 50%;
transform: translateX(-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; padding-inline: 1rem;
min-width: min(600px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -47,8 +43,7 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
flex: 1; max-height: min(80vh, 600px);
min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
@@ -58,5 +53,4 @@
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 1rem; 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>;
}
@@ -1,14 +0,0 @@
.kbd {
display: inline-block;
min-width: 1.5rem;
padding: 0.0625rem 0.375rem;
border-radius: 2px;
background-color: $gray-1100;
color: $ui-white;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
font-family: monospace;
font-size: calc(1rem - 4px);
line-height: 1.5;
text-align: center;
white-space: nowrap;
}
@@ -1,7 +0,0 @@
import { PropsWithChildren } from 'react';
import style from './Kbd.module.scss';
export default function Kbd({ children }: PropsWithChildren) {
return <kbd className={style.kbd}>{children}</kbd>;
}
@@ -21,15 +21,4 @@
outline: none; outline: none;
box-shadow: 0 1px 0 0 currentColor; 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 { MouseEvent, ReactNode } from 'react';
import { HiArrowLongRight } from 'react-icons/hi2'; import { IoOpenOutline } from 'react-icons/io5';
import { openLink } from '../../../utils/linkUtils'; import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -10,10 +10,9 @@ interface ExternalLinkProps {
href: string; href: string;
children: ReactNode; children: ReactNode;
inline?: boolean; 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) => { const handleClick = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
openLink(href); openLink(href);
@@ -24,11 +23,10 @@ export default function ExternalLink({ href, inline, className, children }: Exte
href='#!' href='#!'
target='_blank' target='_blank'
rel='noreferrer' rel='noreferrer'
className={cx([style.link, inline && style.inline, className])} className={cx([style.link, inline && style.inline])}
onClick={handleClick} onClick={handleClick}
> >
{children} {children} <IoOpenOutline style={{ fontSize: '1em' }} />
<HiArrowLongRight className={style.icon} />
</a> </a>
); );
} }
@@ -4,13 +4,10 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
box-sizing: border-box; padding-inline: 1rem;
width: min(880px, 90vw); min-width: min(880px, 90vw);
min-height: min(200px, 10vh); min-height: min(200px, 10vh);
max-height: calc(90vh - 1rem); max-width: min(1200px, 90vw);
display: flex;
flex-direction: column;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -20,21 +17,14 @@
outline: none; outline: none;
} }
.compact {
min-width: 0;
width: min(680px, 90vw);
}
.small {
min-width: 0;
width: min(480px, 90vw);
}
.wide { .wide {
top: 4vh; top: 4vh;
width: min(1800px, 98vw); min-width: min(1280px, 96vw);
height: min(88vh, calc(96vh - 1rem)); max-width: min(1600px, 96vw);
min-height: 0; height: 88vh;
max-height: 88vh;
display: flex;
flex-direction: column;
} }
.backdrop { .backdrop {
@@ -51,19 +41,15 @@
.title { .title {
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
.titleText { .titleText {
min-width: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
overflow-wrap: anywhere;
} }
.body { .body {
@@ -71,23 +57,21 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
flex: 1; max-height: 60vh;
min-height: 0;
padding: 0.5rem 1rem;
overflow-y: auto; overflow-y: auto;
scroll-padding-block: 0.5rem;
} }
.wide .body { .wide .body {
flex: 1;
min-height: 0;
max-height: none;
overflow: hidden; overflow: hidden;
} }
.footer { .footer {
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
@@ -12,7 +12,7 @@ interface ModalProps {
title?: string; title?: string;
showCloseButton?: boolean; showCloseButton?: boolean;
showBackdrop?: boolean; showBackdrop?: boolean;
size?: 'small' | 'compact' | 'default' | 'wide'; size?: 'default' | 'wide';
bodyElements: ReactNode; bodyElements: ReactNode;
footerElements?: ReactNode; footerElements?: ReactNode;
onClose: () => void; onClose: () => void;
@@ -38,7 +38,7 @@ export default function Modal({
> >
<BaseDialog.Portal> <BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />} {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}> <div className={style.title}>
{title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />} {title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />}
{showCloseButton && ( {showCloseButton && (
@@ -38,6 +38,8 @@ $progress-bar-br: 3px;
.multiprogress-bar__indicator-bar { .multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black); background-color: var(--background-color-override, $ui-black);
opacity: 0.8; opacity: 0.8;
transition: 1s linear;
transition-property: width;
.multiprogress-bar--ignore-css-override & { .multiprogress-bar--ignore-css-override & {
background-color: $ui-black; background-color: $ui-black;
@@ -1,6 +1,5 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import { getProgress } from '../../utils/getProgress'; import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -9,7 +8,6 @@ import './MultiPartProgressBar.scss';
interface MultiPartProgressBar { interface MultiPartProgressBar {
now: MaybeNumber; now: MaybeNumber;
complete: MaybeNumber; complete: MaybeNumber;
eventId?: string | null;
normalColor: string; normalColor: string;
warning?: MaybeNumber; warning?: MaybeNumber;
warningColor: string; warningColor: string;
@@ -25,7 +23,6 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { const {
now, now,
complete, complete,
eventId,
normalColor, normalColor,
warning, warning,
warningColor, warningColor,
@@ -37,7 +34,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete, eventId); const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0; const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0; const isOvertime = now !== null && now < 0;
@@ -1,3 +1,7 @@
.separator {
border-color: $border-color-ondark;
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -19,15 +23,14 @@
width: 22rem; width: 22rem;
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
border-right: 1px solid $gray-1100; border-right: 1px solid $gray-1100;
box-shadow: $box-shadow-right;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -47,51 +50,22 @@
} }
.header { .header {
flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; height: 3.5rem;
min-height: 4rem; padding-inline: 1.5rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
border-bottom: 1px solid $white-10;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.clientName {
font-size: $aux-text-size;
color: $gray-600;
@include ellipsis-text;
} }
.body { .body {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-block: 0.5rem 1rem;
} }
.group { .note {
display: flex; margin-left: auto;
flex-direction: column; color: $white-20;
&:not(:first-child) {
margin-top: 0.75rem;
}
}
.groupLabel {
padding: 0.5rem 1.25rem 0.25rem;
color: $gray-700;
} }
@@ -1,24 +1,21 @@
import { Dialog } from '@base-ui/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks'; import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { memo, PropsWithChildren } from 'react'; import { memo } from 'react';
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu'; import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
import { isLocalhost, supportsFullscreen } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import Eyebrow from '../eyebrow/Eyebrow';
import ClientLink from './client-link/ClientLink'; import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation'; import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
import OtherAddresses from './other-addresses/OtherAddresses'; import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -35,7 +32,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreenDocument(); const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions(); const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation(); const location = useLocation();
@@ -54,112 +51,69 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} /> <RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<div className={style.headerText}> <Dialog.Title>Ontime</Dialog.Title>
<Dialog.Title className={style.title}>Ontime</Dialog.Title> <IconButton variant='subtle-white' size='large' onClick={onClose}>
{name && <span className={style.clientName}>{name}</span>}
</div>
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
<MenuGroup label='This screen'> {supportsFullscreen && (
{supportsFullscreen && ( <NavigationMenuItem active={fullscreen} onClick={toggle}>
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} /> Toggle Fullscreen
)} {fullscreen ? <IoContract /> : <IoExpand />}
<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> </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>
<MenuGroup label='Ontime app'> <hr className={style.separator} />
<EditorNavigation />
<EditorNavigation />
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline />
Operator
</ClientLink>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<ClientLink <ClientLink
to='cuesheet' key={route.url}
current={location.pathname === '/cuesheet'} to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined} postAction={isSmallScreen ? onClose : undefined}
> >
<IoLockClosedOutline /> {route.label}
Cuesheet
</ClientLink> </ClientLink>
<ClientLink ))}
to='op'
current={location.pathname === '/op'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Operator
</ClientLink>
</MenuGroup>
<MenuGroup label='Views'>
{navigatorConstants.map((route) => (
<ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label}
</ClientLink>
))}
</MenuGroup>
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />} {isLocalhost && (
<div>
<OtherAddresses currentLocation={location.pathname} />
</div>
)}
</Dialog.Popup> </Dialog.Popup>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </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();
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
if (navPresets.length === 0) return null;
return (
<MenuGroup label='Presets'>
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
to={`preset/${preset.alias}`}
current={location.pathname === `/preset/${preset.alias}`}
postAction={isSmallScreen ? onClose : undefined}
>
{preset.alias}
</ClientLink>
))}
</MenuGroup>
);
}
@@ -1,8 +1,6 @@
import { useHotkeys } from '@mantine/hooks'; import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo, useCallback, useState } from 'react'; import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store'; import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
@@ -15,37 +13,19 @@ interface ViewNavigationMenuProps {
suppressSettings?: boolean; 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); export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams);
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
useHotkeys([ useHotkeys([
[ [
'Space', 'Space',
(event) => { () => {
if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return; if (isNavigationLocked) return;
event.preventDefault(); menuHandler.toggle();
toggleMenu();
}, },
{ preventDefault: false }, { preventDefault: true },
], ],
[ [
'mod + ,', 'mod + ,',
@@ -64,11 +44,10 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
return ( return (
<> <>
<FloatingNavigation <FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : toggleMenu} toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges}
/> />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />} {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</> </>
); );
} }
@@ -1,29 +1,4 @@
.trailing {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.linkIcon { .linkIcon {
margin-left: auto;
@include rotate-fourty-five; @include rotate-fourty-five;
} }
.indicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
flex-shrink: 0;
}
.clear {
opacity: 0.6;
transition: opacity $transition-time-action;
&:hover,
&:focus-visible {
opacity: 1;
}
}
@@ -1,17 +1,9 @@
import { MouseEvent, PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoArrowUp, IoCloseOutline } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import { useLocation, useNavigate, useSearchParams } from 'react-router'; import { useNavigate } from 'react-router';
import { useElectronEvent } from '../../../hooks/useElectronEvent'; import { useElectronEvent } from '../../../hooks/useElectronEvent';
import {
hasCustomParams,
reservedParams,
stripReservedParams,
useSavedViewParams,
} from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils'; import { handleLinks } from '../../../utils/linkUtils';
import IconButton from '../../buttons/IconButton';
import Tooltip from '../../tooltip/Tooltip';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss'; import style from './ClientLink.module.scss';
@@ -24,104 +16,32 @@ interface ClientLinkProps {
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) { export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const navigate = useNavigate();
if (isElectron) { if (isElectron) {
return ( return (
<ElectronNavigationItem current={current} to={to} postAction={postAction}> <NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children} {children}
</ElectronNavigationItem> <IoArrowUp className={style.linkIcon} />
</NavigationMenuItem>
); );
} }
return ( return (
<BrowserNavigationItem current={current} to={to} postAction={postAction}> <NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children} {children}
</BrowserNavigationItem>
);
}
function ElectronNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigateToLink = () => {
handleLinks(to);
postAction?.();
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
<span className={style.trailing}>
<IoArrowUp className={style.linkIcon} />
</span>
</NavigationMenuItem>
);
}
function BrowserNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { params: savedParams, save, clear } = useSavedViewParams();
/**
* Save the params of the view we are leaving and resolve the destination,
* restoring any params previously saved for the target view.
*/
const resolveDestination = () => {
const currentView = location.pathname.replace(/^\//, '');
save(currentView, stripReservedParams(location.search));
const restored = savedParams[to];
return `${to}${restored ? `?${restored}` : ''}`;
};
const isCustomised = current ? hasCustomParams(searchParams) : Boolean(savedParams[to]);
const navigateToLink = () => {
const destination = resolveDestination();
navigate(`/${destination}`);
postAction?.();
};
/**
* Clear the saved settings for this route without navigating to it.
* When it is the current view, also reset the live URL to its defaults.
*/
const clearViewSettings = (event: MouseEvent) => {
event.stopPropagation();
clear(to);
// if mounted, clear the URL params
if (current) {
const preserved = new URLSearchParams();
reservedParams.forEach((key) => {
const value = searchParams.get(key);
if (value !== null) preserved.set(key, value);
});
setSearchParams(preserved);
}
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
{isCustomised && (
<span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<Tooltip
text='Reset to default'
render={
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Reset to default'
onClick={clearViewSettings}
/>
}
>
<IoCloseOutline />
</Tooltip>
</span>
)}
</NavigationMenuItem> </NavigationMenuItem>
); );
} }
@@ -22,19 +22,3 @@
top: 0; top: 0;
z-index: $zindex-nav; z-index: $zindex-nav;
} }
.buttonWithIndicator {
position: relative;
display: inline-flex;
}
.indicator {
position: absolute;
top: -0.15rem;
right: -0.15rem;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
}
@@ -9,10 +9,9 @@ import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps { interface FloatingNavigationProps {
toggleMenu?: () => void; toggleMenu?: () => void;
toggleSettings?: () => void; toggleSettings?: () => void;
hasSavedChanges?: boolean;
} }
export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSavedChanges }: FloatingNavigationProps) { export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity(true); const isButtonShown = useFadeOutOnInactivity(true);
return ( return (
@@ -21,18 +20,15 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSave
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])} className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
> >
{toggleMenu && ( {toggleMenu && (
<div className={style.buttonWithIndicator}> <IconButton
<IconButton variant='subtle-white'
variant='subtle-white' size='xlarge'
size='xlarge' onClick={toggleMenu}
onClick={toggleMenu} aria-label='toggle menu'
aria-label='toggle menu' data-testid='navigation__toggle-menu'
data-testid='navigation__toggle-menu' >
> <IoApps />
<IoApps /> </IconButton>
</IconButton>
{hasSavedChanges && <span className={style.indicator} data-testid='navigation__saved-indicator' />}
</div>
)} )}
{toggleSettings && ( {toggleSettings && (
<IconButton <IconButton
@@ -2,68 +2,35 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.625rem 1.25rem; padding: 0.75rem 1.5rem;
gap: 0.625rem; gap: 0.5rem;
width: 100%; width: 100%;
border-left: 3px solid transparent; border-left: 4px solid transparent;
color: $gray-200; color: $action-text-color;
font-size: $text-body-size;
white-space: nowrap; white-space: nowrap;
text-align: left;
cursor: pointer; cursor: pointer;
transition-property: color, background-color; transition-property: color;
transition-duration: $transition-time-action; 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 { &:hover {
background-color: $white-3; color: $ontime-color;
color: $ui-white; background-color: $gray-1350;
> svg {
color: $gray-300;
}
} }
&:active { &:active {
background-color: $white-7; background-color: $border-color-ondark;
} }
&:focus-visible { &:focus {
outline: 2px solid $blue-500; outline: 2px solid $blue-500;
outline-offset: -2px; background-color: $gray-1350;
background-color: $white-3;
}
// toggle rows are labels, the focusable control sits inside them
&:focus-within {
background-color: $white-3;
} }
&.current { &.current {
background-color: $bg-active; background-color: $gray-1300;
border-left-color: $blue-400; border-left: 4px solid $action-text-color;
color: $ui-white;
font-weight: 600;
> svg {
color: $blue-300;
}
} }
} }
.label {
flex: 1;
min-width: 0;
@include ellipsis-text;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent'; import { isKeyEnter } from '../../../utils/keyEvent';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import style from './NavigationMenuItem.module.scss'; import style from './NavigationMenuItem.module.scss';
@@ -11,7 +11,6 @@ interface NavigationMenuItemProps {
onClick: () => void; onClick: () => void;
} }
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
export default function NavigationMenuItem({ export default function NavigationMenuItem({
active, active,
className, className,
@@ -25,9 +24,7 @@ export default function NavigationMenuItem({
role='button' role='button'
onClick={onClick} onClick={onClick}
onKeyDown={(event) => { onKeyDown={(event) => {
if (isKeyEnter(event) || isKeySpace(event)) { if (isKeyEnter(event)) {
event.preventDefault();
event.stopPropagation();
onClick(); 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 { .header {
padding: 0 1.25rem 0.25rem; font-size: calc(1rem - 2px);
margin-left: 1rem;
color: $gray-700; color: $gray-700;
} }
.interfaces { .interfaces {
padding: 0.25rem 1.25rem 0; padding: 0.5rem 0.5rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.25rem; gap: 0.5rem;
}
.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;
}
}
} }
.interfaceCopy { .interfaceCopy {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0;
width: 100%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.goIcon { .goIcon {
flex: 0 0 auto;
@include rotate-fourty-five; @include rotate-fourty-five;
margin-left: 0.25rem; margin-left: 0.25rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
@@ -3,8 +3,6 @@ import { IoArrowUp } from 'react-icons/io5';
import useInfo from '../../../hooks-query/useInfo'; import useInfo from '../../../hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../utils/linkUtils'; import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
import CopyTag from '../../copy-tag/CopyTag'; import CopyTag from '../../copy-tag/CopyTag';
import Eyebrow from '../../eyebrow/Eyebrow';
import { getExternalInterfaces } from './otherAddresses.utils';
import style from './OtherAddresses.module.scss'; import style from './OtherAddresses.module.scss';
@@ -14,33 +12,32 @@ interface OtherAddressesProps {
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) { export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
const { data } = useInfo(); 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 null;
} }
return ( return (
<div className={style.footer}> <>
<div className={style.header}> <div className={style.header}>Accessible on external networks</div>
<Eyebrow>Also available at</Eyebrow>
</div>
<div className={style.interfaces}> <div className={style.interfaces}>
{externalInterfaces.map((nif) => { {data?.networkInterfaces?.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation); const address = linkToOtherHost(nif.address, currentLocation);
return ( return (
<div key={nif.name} className={style.interface}> <CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'> <span className={style.interfaceCopy}>
<span className={style.interfaceCopy}> {nif.address} <IoArrowUp className={style.goIcon} />
<span className={style.address}>{nif.address}</span> </span>
<IoArrowUp className={style.goIcon} /> </CopyTag>
</span>
</CopyTag>
</div>
); );
})} })}
</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');
}
@@ -14,4 +14,6 @@ $progress-bar-br: 3px;
.progress-bar__indicator { .progress-bar__indicator {
height: $progress-bar-size; height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color); background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
transition-property: width;
} }
@@ -1,18 +1,18 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress'; import { getProgress } from '../../utils/getProgress';
import './ProgressBar.scss'; import './ProgressBar.scss';
interface ProgressBarProps { interface ProgressBarProps {
current: MaybeNumber; current: MaybeNumber;
duration: MaybeNumber; duration: MaybeNumber;
eventId?: string | null;
className?: string; className?: string;
} }
export default function ProgressBar({ current, duration, eventId, className }: ProgressBarProps) { export default function ProgressBar(props: ProgressBarProps) {
const progress = useAnimatedProgress(current, duration, eventId); const { current, duration, className } = props;
const progress = getProgress(current, duration);
return ( return (
<div className={`progress-bar__bg ${className}`}> <div className={`progress-bar__bg ${className}`}>
@@ -1,43 +1,18 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: var(--secondary-color-override, $viewer-secondary-color); color: $white-10;
.empty { .empty {
display: block; width: 100%;
width: min(100%, 14rem); opacity: 0.8;
margin: 0 auto -1.5rem;
opacity: 0.6;
} }
.text { .text {
display: block; display: block;
margin-inline: auto; margin-inline: auto;
font-weight: 400; font-weight: 600;
font-size: clamp(1rem, 1.55vw, 1.5rem); font-size: 2em;
line-height: 1.35; max-width: 600px;
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;
} }
} }
@@ -1,5 +1,4 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -10,18 +9,12 @@ interface EmptyProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
variant?: 'error';
} }
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) { export default function Empty({ text, className, injectedStyles }: EmptyProps) {
return ( return (
<div <div className={cx([style.emptyContainer, className])} style={injectedStyles}>
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} /> <EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </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 */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
height: 100dvh; height: 100vh;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color); background: var(--background-color-override, $viewer-background-color);
@@ -16,6 +16,5 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; padding-top: 5rem;
padding-block: 5rem;
} }
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps { interface EmptyPageProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
variant?: 'error';
} }
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} variant={variant} /> <Empty text={text} injectedStyles={injectedStyles} />
</div> </div>
); );
} }
@@ -15,4 +15,9 @@
gap: 1rem; gap: 1rem;
margin-top: 1em; margin-top: 1em;
} }
.text {
font-weight: 600;
font-size: 2em;
}
} }
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} /> <Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && ( {handleAddNew && (
<div className={style.inline}> <div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'> <Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -11,11 +11,6 @@
color: $ui-white; color: $ui-white;
} }
.active {
border: 1px solid $green-700;
color: $green-400;
}
.warning { .warning {
background-color: $orange-1300; background-color: $orange-1300;
color: $orange-300; color: $orange-300;
@@ -6,7 +6,7 @@ import style from './Tag.module.scss';
interface TagProps { interface TagProps {
className?: string; className?: string;
variant?: 'default' | 'active' | 'warning'; variant?: 'default' | 'warning';
} }
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) { export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
@@ -2,12 +2,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
> span {
font-variant-numeric: tabular-nums;
text-align: right;
text-transform: uppercase;
}
} }
// attempt to match with ontimeTextInputs // attempt to match with ontimeTextInputs
@@ -1,77 +1,37 @@
.multiOptionList { .inline {
overflow-y: auto; display: inline-flex;
max-height: 12rem; align-items: center;
background: $gray-1200; flex-wrap: wrap;
border: 1px solid $gray-1100; gap: 1rem;
border-radius: $component-border-radius-md;
} }
// styles from subtle button
.toggleSelect { .toggleSelect {
box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.25rem;
min-height: 2.5rem; padding-inline: 0.5rem;
padding: 0.5rem 0.75rem; height: 2.5rem;
background: transparent; background: $gray-1050;
border: 0; color: $ui-white;
color: $gray-300;
line-height: 1em; line-height: 1em;
cursor: pointer; border-radius: $component-border-radius-md;
transition:
background-color $transition-time-action,
border-color $transition-time-action,
color $transition-time-action;
&:hover { &:hover:not(:disabled):not(:active) {
background: $gray-1000; background: $gray-1000;
color: $ui-white; color: $blue-500;
} }
&:active { &:active:not(:disabled) {
background: $gray-1100; background: $gray-1100;
} border-color: $gray-1250;
&: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;
}
} }
&::after { &::after {
content: ''; content: '';
flex: 0 0 auto; margin-left: 0.25rem;
margin-left: auto; width: 0.75em;
width: 0.625rem; height: 0.75em;
height: 0.625rem;
background: var(--user-bg, $gray-900); background: var(--user-bg, $gray-900);
border-radius: 50%; border-radius: 50%;
} }
@@ -2,7 +2,6 @@ import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../views/common/viewUtils'; import { isStringBoolean } from '../../../views/common/viewUtils';
import { cx } from '../../utils/styleUtils';
import Checkbox from '../checkbox/Checkbox'; import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select'; import Select, { SelectOption } from '../select/Select';
@@ -108,20 +107,18 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
return ( return (
<> <>
<input name={id} hidden readOnly value={paramState.join(',')} /> <input name={id} hidden readOnly value={paramState.join(',')} />
<div className={style.multiOptionList}> <div className={style.inline}>
{values.map((option) => { {values.map((option) => {
const selected = paramState.includes(option.value);
return ( return (
<label <label
key={option.value} key={option.value}
className={cx([style.toggleSelect, selected && style.selected])} className={style.toggleSelect}
style={{ style={{
'--user-bg': option.colour, '--user-bg': option.colour,
}} }}
> >
<Checkbox <Checkbox
checked={selected} checked={paramState.includes(option.value)}
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)} onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
/> />
{option.label} {option.label}
@@ -1,3 +1,13 @@
.drawerFooter {
display: flex;
justify-content: end;
gap: $section-spacing;
button {
padding: 0 2em;
}
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -24,11 +34,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem 1.5rem;
background-color: $ui-black; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
border-left: 1px solid $gray-1100; border-left: 1px solid $gray-1100;
box-shadow: $box-shadow-l1;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -48,54 +58,36 @@
} }
.header { .header {
flex: 0 0 auto; padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; height: 3.5rem;
min-height: 4rem;
padding: 0.75rem 1rem;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.target {
font-size: $aux-text-size;
color: $gray-600;
} }
.body { .body {
flex: 1; flex: 1;
padding: 1rem; padding-inline: 1rem;
padding-bottom: 10vh;
overflow-y: auto; overflow-y: auto;
} }
.footer { .footer {
flex: 0 0 auto;
display: flex; display: flex;
padding: 0.75rem 1rem; padding-inline: 1rem;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
background-color: $ui-black;
border-top: 1px solid $white-10;
} }
.sectionList { .sectionList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 2rem;
padding-right: 0.5rem;
} }
.info { .info {
@@ -4,10 +4,8 @@ import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { viewLabels } from '../../../viewerConfig';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import Info from '../info/Info'; import Info from '../info/Info';
@@ -29,7 +27,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
const clearSavedParams = useSavedViewParams((store) => store.clear);
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions); const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
@@ -39,7 +36,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
}; };
const resetParams = () => { const resetParams = () => {
clearSavedParams(target);
setSearchParams(getPreservedParams()); setSearchParams(getPreservedParams());
}; };
@@ -72,17 +68,8 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Dialog.Backdrop className={style.backdrop} /> <Dialog.Backdrop className={style.backdrop} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<div className={style.headerText}> <Dialog.Title>Customise</Dialog.Title>
<Dialog.Title className={style.title}>Customise</Dialog.Title> <IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
<span className={style.target}>{viewLabels[target]}</span>
</div>
<IconButton
variant='subtle-white'
size='large'
aria-label='Close'
data-testid='close-view-params'
onClick={handleClose}
>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
@@ -1,21 +1,10 @@
.presetSection { .presetSection {
background-color: $gray-1350; 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; display: flex;
align-items: center; flex-direction: column;
gap: 0.25rem;
color: $gray-300; padding: 1rem 0.5rem;
} margin-bottom: 1rem;
.presetList {
border-top: 1px solid $white-7;
max-height: 10rem; max-height: 10rem;
overflow-y: auto; overflow-y: auto;
scrollbar-gutter: stable; scrollbar-gutter: stable;
@@ -25,39 +14,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; 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 { &.active {
background-color: $bg-active; color: $blue-500;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
font-weight: 600;
} }
} }
.presetName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presetActions { .presetActions {
margin-left: auto; 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 { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import Eyebrow from '../eyebrow/Eyebrow';
import style from './ViewParamsPresets.module.scss'; import style from './ViewParamsPresets.module.scss';
@@ -27,31 +26,22 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
return ( return (
<div className={style.presetSection}> <div className={style.presetSection}>
<div className={style.header}> {viewPresets.map((preset) => {
<Eyebrow>Saved presets</Eyebrow> const active = searchParams.get('alias') === preset.alias;
</div> return (
<div className={style.presetList}> <div key={preset.alias} className={cx([style.preset, active && style.active])}>
{viewPresets.map((preset) => { <div>{preset.alias}</div>
const active = searchParams.get('alias') === preset.alias; <Button
return ( variant={active ? 'ghosted' : 'subtle-white'}
<div key={preset.alias} className={cx([style.preset, active && style.active])}> onClick={() => handleRecall(preset)}
<span className={style.presetName}>{preset.alias}</span> disabled={active}
{active ? ( className={style.presetActions}
<span className={style.current}>Current</span> >
) : ( {active ? 'Applied' : 'Apply'}
<Button </Button>
variant='ghosted' </div>
size='small' );
onClick={() => handleRecall(preset)} })}
className={style.presetActions}
>
Apply
</Button>
)}
</div>
);
})}
</div>
</div> </div>
); );
} }
@@ -1,118 +1,48 @@
.section { .section {
overflow: hidden;
color: $ui-white; color: $ui-white;
font-size: 1rem; font-size: 1rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
} }
.sectionHeader { .sectionHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.5rem; height: 2rem;
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem;
background: transparent;
border: 0;
font: inherit;
text-align: left;
color: $gray-300;
&.collapsible { &.collapsible {
cursor: pointer; 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 { .label {
display: grid; margin-top: $section-spacing;
grid-template-columns: minmax(0, 1fr);
row-gap: 0.25rem;
padding: 0.75rem 0;
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray;
&:not(:last-child) { display: flex;
border-bottom: 1px solid $white-7; flex-direction: column;
} gap: 0.25rem;
}
/**
* 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;
}
} }
.title { .title {
color: $ui-white; font-size: $inner-section-text-size;
display: block;
width: 100%;
} }
.description { .description {
color: $gray-400; font-size: $inner-section-text-size;
font-size: $aux-text-size; display: block;
line-height: 1.4; 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 { .hidden {
@@ -1,9 +1,7 @@
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from 'react-icons/io5'; import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Eyebrow from '../eyebrow/Eyebrow';
import { OptionTitle } from './constants'; import { OptionTitle } from './constants';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types'; import { type ParamField } from './viewParams.types';
@@ -16,74 +14,50 @@ interface ViewParamsSectionProps {
options: ParamField[]; 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) { export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); 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 const handleCollapse = () => {
if (title === OptionTitle.Hidden) { if (collapsible) {
return <HiddenContents options={options} />; setCollapsed((prev) => !prev);
} }
};
const isCollapsed = Boolean(collapsible && collapsed);
return ( return (
<section className={style.section}> <section className={style.section}>
<SectionHeader {title === OptionTitle.Hidden ? (
title={title} <HiddenContents options={options} />
isCustomised={options.some((option) => searchParams.has(option.id))} ) : (
collapsed={isCollapsed} <>
onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined} <div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
/> {title}
{/* collapsed options stay mounted: the form reads its values from the DOM */} {collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
<div className={cx([style.options, isCollapsed && style.hidden])}> </div>
{options.map((option) => ( <SectionContents options={options} collapsed={collapsed} />
<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>
))}
</div>
</section> </section>
); );
} }
interface SectionHeaderProps { interface SectionContentsProps {
title: string; options: ParamField[];
isCustomised: boolean;
collapsed: boolean; collapsed: boolean;
/** a header without a toggle belongs to a section which cannot collapse */
onToggle?: () => void;
} }
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) { function SectionContents({ options, collapsed }: SectionContentsProps) {
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>;
}
return ( return (
<button <>
type='button' {options.map((option) => {
className={cx([style.sectionHeader, style.collapsible])} return (
aria-expanded={!collapsed} <label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
onClick={onToggle} <span className={style.title}>{option.title}</span>
> <span className={style.description}>{option.description}</span>
{label} <ParamInput paramField={option} />
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} /> </label>
</button> );
})}
</>
); );
} }
@@ -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 };
}
+100 -31
View File
@@ -1,38 +1,62 @@
import { EntryId, OntimeEntry } from 'ontime-types'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react'; 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 { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { flattenRundown, useRundownById } from './useRundownById'; 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() { export default function useRundown() {
const { rundownId } = useRundownScope(); const queryClient = useQueryClient();
return useRundownById(rundownId); const {
} data: { loaded: loadedRundownId },
} = useProjectRundowns();
/** const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
* Runtime state only describes the loaded rundown, queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
* a scope pointed elsewhere must not show a playing event queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
*/ refetchInterval: queryRefetchIntervalSlow,
export function useScopedSelectedEventId(): EntryId | null { });
const { isLoaded } = useRundownScope();
const selectedEventId = useSelectedEventId(); // Seed the id-keyed cache when fetching via the bootstrap alias
return isLoaded ? selectedEventId : null; 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() { export function useRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const selectedEventId = useScopedSelectedEventId(); const selectedEventId = useSelectedEventId();
// key on the fields the derivation reads, a revision only change must not churn the list const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data, status, rundownMetadata }; return { data, status, rundownMetadata };
} }
@@ -42,23 +66,41 @@ export function useRundownWithMetadata() {
*/ */
export function useFlatRundown() { export function useFlatRundown() {
const { data, status } = useRundown(); 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 }; return { data: flatRundown, rundownId: data.id, status };
} }
export function useFlatRundownWithMetadata() { export function useFlatRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const selectedEventId = useScopedSelectedEventId(); const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data; const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data: rundownWithMetadata, status }; 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 * 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; if (entryId === null) return null;
return rundown.entries[entryId] ?? 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,66 +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 })),
placeholderData: (previousData, _previousQuery) => previousData,
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],
);
}
@@ -1,49 +0,0 @@
import { EntryId, MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { useIsOnline, 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 {
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 [, 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
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;
// while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => {
if (!shouldAnimate) {
return;
}
let frame = requestAnimationFrame(function tick() {
setTick((value) => value + 1);
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// Derive from the anchor plus elapsed time at render; freeze while disconnected or not running.
const anchored = baseline.current.current;
const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored;
return getProgress(value, duration);
}
+26 -84
View File
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
MaybeString, MaybeString,
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
@@ -8,6 +9,7 @@ import {
OntimeGroup, OntimeGroup,
OntimeMilestone, OntimeMilestone,
PatchWithId, PatchWithId,
ProjectRundownsList,
Rundown, Rundown,
SupportedEntry, SupportedEntry,
TimeField, TimeField,
@@ -31,10 +33,9 @@ import {
swapEventData, swapEventData,
} from 'ontime-utils'; } from 'ontime-utils';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import isEqual from 'react-fast-compare';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; 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 { import {
ReorderEntry, ReorderEntry,
deleteEntries, deleteEntries,
@@ -49,10 +50,8 @@ import {
requestEventSwap, requestEventSwap,
requestGroupEntries, requestGroupEntries,
requestUngroup, requestUngroup,
requestFitGroupTarget,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useRundownScope } from '../context/RundownScopeContext';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
@@ -61,42 +60,20 @@ export type EventOptions = Partial<{
before: MaybeString; before: MaybeString;
// options of entries of type OntimeEvent // options of entries of type OntimeEvent
linkPrevious: boolean; linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString; lastEventId: MaybeString;
}>; }>;
/** /**
* Applies a patch the way the server does, revision included. * Gather utilities for actions on entries in the loaded rundown.
*
* 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 { export const useEntryActions = () => useEntryActionsForRundown(undefined);
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.
*/
export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId);
/** /**
* Gather utilities for actions on entries in an explicitly selected rundown. * 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 queryClient = useQueryClient();
const { const {
linkPrevious, linkPrevious,
@@ -110,8 +87,12 @@ function useEntryActionsForRundown(scopedRundownId: string) {
} = useEditorSettings(); } = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => { const resolveCurrentRundownQueryKey = useCallback(() => {
return getRundownCacheKey(scopedRundownId); if (scopedRundownId !== undefined) {
}, [scopedRundownId]); 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 * Returns the currently loaded rundown
@@ -139,7 +120,7 @@ function useEntryActionsForRundown(scopedRundownId: string) {
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry), mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => { onMutate: async ([_rundownId, entry]) => {
const queryKey = resolveCurrentRundownQueryKey(); const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey }); await queryClient.cancelQueries({ queryKey });
@@ -168,9 +149,8 @@ function useEntryActionsForRundown(scopedRundownId: string) {
addToRundown( addToRundown(
newRundown, newRundown,
optimisticEntry, optimisticEntry,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId, afterId,
entry.before ?? null, parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
); );
queryClient.setQueryData<Rundown>(queryKey, newRundown); queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -213,7 +193,7 @@ function useEntryActionsForRundown(scopedRundownId: string) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() }; const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types // handle adding options that concern all event types
if (options?.after) { if (options?.after) {
@@ -225,8 +205,8 @@ function useEntryActionsForRundown(scopedRundownId: string) {
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
if (options?.lastEventId) { if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const previousEvent = rundownData.entries[options?.lastEventId]; const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd; newEntry.timeStart = previousEvent.timeEnd;
@@ -304,7 +284,7 @@ function useEntryActionsForRundown(scopedRundownId: string) {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId, options?: ClientInsertOptions) => { async (entryId: EntryId, options?: InsertOptions) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
@@ -338,10 +318,8 @@ function useEntryActionsForRundown(scopedRundownId: string) {
if (previousData && eventId) { if (previousData && eventId) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.entries }; const newRundown = { ...previousData.entries };
const previousEntry = newRundown[eventId]; // @ts-expect-error -- we expect the events to be of same type
if (previousEntry) { newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
newRundown[eventId] = patchEntry(previousEntry, newEvent);
}
queryClient.setQueryData<Rundown>(queryKey, { queryClient.setQueryData<Rundown>(queryKey, {
id: previousData.id, id: previousData.id,
title: previousData.title, title: previousData.title,
@@ -355,23 +333,6 @@ function useEntryActionsForRundown(scopedRundownId: string) {
// Return a context with the previous and new events // Return a context with the previous and new events
return { previousData, newEvent, queryKey }; 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 // Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
if (context?.previousData && context?.queryKey) { if (context?.previousData && context?.queryKey) {
@@ -499,27 +460,7 @@ function useEntryActionsForRundown(scopedRundownId: string) {
return previousEnd; return previousEnd;
} }
}, },
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey], [getCurrentRundownData, updateEntryMutation, queryClient],
);
/**
* 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],
); );
/** /**
@@ -544,7 +485,10 @@ function useEntryActionsForRundown(scopedRundownId: string) {
if (Object.hasOwn(newRundown, eventId)) { if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId]; const event = newRundown[eventId];
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
newRundown[eventId] = patchEntry(event, data.data); newRundown[eventId] = {
...event,
...data,
};
} }
} }
}); });
@@ -1059,7 +1003,6 @@ function useEntryActionsForRundown(scopedRundownId: string) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
}), }),
[ [
addEntry, addEntry,
@@ -1077,7 +1020,6 @@ function useEntryActionsForRundown(scopedRundownId: string) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
], ],
); );
} }
@@ -1115,7 +1057,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/** /**
* Utility to create an optimistic entry for immediate cache insertion * Utility to create an optimistic entry for immediate cache insertion
*/ */
function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry { function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload; const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id; const id = entryData.id;
let parent: EntryId | null = null; let parent: EntryId | null = null;
-12
View File
@@ -139,17 +139,6 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined), pause: () => sendSocket('pause', undefined),
}; };
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
phase: state.timer.phase,
addedTime: state.timer.addedTime,
secondaryTimer: state.timer.secondaryTimer,
current: state.timer.current,
expectedFinish: state.timer.expectedFinish,
startedAt: state.timer.startedAt,
isCountToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useTimer = createSelector((state: RuntimeStore) => ({ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer, ...state.timer,
})); }));
@@ -163,7 +152,6 @@ export const useNextFlag = createSelector((state: RuntimeStore) => ({
export const useProgressData = createSelector((state: RuntimeStore) => ({ export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current, current: state.timer.current,
duration: state.timer.duration, duration: state.timer.duration,
eventId: state.eventNow?.id ?? null,
timeWarning: state.eventNow?.timeWarning ?? null, timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
@@ -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,87 +0,0 @@
import { hasCustomParams, stripReservedParams, useSavedViewParams } from '../savedViewParams';
describe('savedViewParams store', () => {
beforeEach(() => {
useSavedViewParams.getState().clearAll();
});
it('saves and restores params per view', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=true');
expect(useSavedViewParams.getState().params.backstage).toBe('showProgress=false');
});
it('overwrites the saved params for a view on subsequent saves', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', 'hideSeconds=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=false');
});
it('does not store an entry for an empty search string', () => {
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('removes a previously saved entry when saved with an empty search string', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('ignores empty view keys', () => {
useSavedViewParams.getState().save('', 'hideSeconds=true');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('clears the saved params for a single view without touching others', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('is a no-op when clearing a view without saved params', () => {
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('clears all saved params', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().clearAll();
expect(useSavedViewParams.getState().params).toEqual({});
});
});
describe('stripReservedParams', () => {
it('removes reserved auth/preset params while keeping view customisation', () => {
expect(stripReservedParams('hideSeconds=true&token=abc&n=1&alias=my')).toBe('hideSeconds=true');
});
it('returns an empty string when only reserved params are present', () => {
expect(stripReservedParams('token=abc&n=1&alias=my')).toBe('');
});
});
describe('hasCustomParams', () => {
it('is true when a non-reserved param is present', () => {
expect(hasCustomParams(new URLSearchParams('hideSeconds=true&token=abc'))).toBe(true);
});
it('is false when only reserved params are present', () => {
expect(hasCustomParams(new URLSearchParams('token=abc&n=1&alias=my'))).toBe(false);
});
it('is false when there are no params', () => {
expect(hasCustomParams(new URLSearchParams(''))).toBe(false);
});
});
@@ -1,21 +1,14 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
type EntryCopyStore = { type EntryCopyStore = {
entryCopyId: MaybeString; entryCopyId: string | null;
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
entryCopyRundownId: MaybeString;
entryCopyMode: 'copy' | 'cut'; 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) => ({ export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null, entryCopyId: null,
entryCopyRundownId: null,
entryCopyMode: 'copy', entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') => setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }), 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';
}
@@ -1,62 +0,0 @@
import { create } from 'zustand';
// params that are auth/preset markers, not user view customisation (see common/utils/urlPresets.ts)
export const reservedParams = new Set(['token', 'n', 'alias']);
interface SavedViewParamsStore {
params: Record<string, string>; // view key (e.g. "timer") -> search string without leading "?"
save: (view: string, search: string) => void;
clear: (view: string) => void;
clearAll: () => void;
}
/**
* Remembers the last view parameters used for each view so they can be
* restored when the user navigates back to that view through the menu.
* In-memory only: persists across SPA navigation, resets on a full page reload.
*/
export const useSavedViewParams = create<SavedViewParamsStore>((set) => ({
params: {},
save: (view, search) =>
set((state) => {
// ignore empty view keys and do not store empty entries, so the
// "saved changes" indicator only reflects genuine customisation
if (!view) return state;
const params = { ...state.params };
if (search) {
params[view] = search;
} else {
delete params[view];
}
return { params };
}),
clear: (view) =>
set((state) => {
if (!state.params[view]) return state;
const params = { ...state.params };
delete params[view];
return { params };
}),
clearAll: () => set({ params: {} }),
}));
/**
* Removes reserved (auth/preset) params from a search string, keeping only
* genuine view customisation.
*/
export function stripReservedParams(search: string): string {
const sp = new URLSearchParams(search);
reservedParams.forEach((key) => sp.delete(key));
return sp.toString();
}
/**
* Whether the search params contain any genuine view customisation,
* ignoring reserved params.
*/
export function hasCustomParams(searchParams: URLSearchParams): boolean {
for (const key of searchParams.keys()) {
if (!reservedParams.has(key)) return true;
}
return false;
}
@@ -1,4 +1,4 @@
import { hostToBaseUrl, linkToOtherHost } from '../linkUtils'; import { linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => { describe('linkToOTherHost', () => {
it('should handle electron links', () => { it('should handle electron links', () => {
@@ -22,18 +22,3 @@ describe('linkToOTherHost', () => {
expect(destination).toBe('https://app.getontime.no/user-hash/path'); expect(destination).toBe('https://app.getontime.no/user-hash/path');
}); });
}); });
describe('hostToBaseUrl', () => {
it('uses http when the page is plain http', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'http://localhost:4001')).toBe('http://192.168.10.166:4001');
});
it('keeps https when the page reaches the server over TLS on the same port', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://localhost:4001')).toBe('https://192.168.10.166:4001');
});
it('falls back to http when the page is https on a different port (TLS proxy)', () => {
// the raw server port does not speak TLS, an https link to it would fail
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://ontime.example.com')).toBe('http://192.168.10.166:4001');
});
});
@@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import { removeFileExtension } from '../uploadUtils';
describe('removeFileExtension()', () => {
it('removes a trailing extension', () => {
expect(removeFileExtension('show.xlsx')).toBe('show');
});
it('only removes the last extension', () => {
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
});
it('returns the name unchanged when there is no extension', () => {
expect(removeFileExtension('rundown')).toBe('rundown');
});
it('does not treat a leading dot as an extension', () => {
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
});
it('handles an empty string', () => {
expect(removeFileExtension('')).toBe('');
});
});
@@ -37,7 +37,6 @@ describe('getRouteFromPreset()', () => {
alias: 'demopage', alias: 'demopage',
target: OntimeView.Timer, target: OntimeView.Timer,
search: 'user=guest', search: 'user=guest',
displayInNav: false,
options: {}, options: {},
}, },
]; ];
@@ -120,7 +119,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-4685d6', alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -133,7 +131,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-basic', alias: 'cuesheet-basic',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}, },
]; ];
const cuesheetPresetWithNavLock: URLPreset[] = [ const cuesheetPresetWithNavLock: URLPreset[] = [
@@ -142,7 +139,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-locked', alias: 'cuesheet-locked',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: 'n=1', search: 'n=1',
displayInNav: false,
}, },
]; ];
@@ -224,7 +220,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -236,7 +231,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -248,7 +242,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -260,7 +253,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
])('should generate URL preset options for %s', (_description, alias, url, expected) => { ])('should generate URL preset options for %s', (_description, alias, url, expected) => {
-4
View File
@@ -4,10 +4,6 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Enter'; 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 { export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape'; return event.key === 'Escape';
} }
+1 -20
View File
@@ -37,12 +37,7 @@ export function handleLinks(
event?.preventDefault(); event?.preventDefault();
const destination = new URL(externalServerUrl); const destination = new URL(externalServerUrl);
// split off any query string so it is not encoded into the pathname destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
const [pathname, search] = location.split('?');
destination.pathname = externalBaseURI ? `${externalBaseURI}/${pathname}` : pathname;
if (search) {
destination.search = search;
}
openLink(destination.toString()); openLink(destination.toString());
} }
@@ -59,17 +54,3 @@ export function linkToOtherHost(
} }
return destination.toString(); return destination.toString();
} }
/**
* Builds a base URL for reaching the Ontime server on a given network interface.
* The Ontime server itself only speaks HTTP, so we only keep https when the page
* already reaches the server over TLS on that same port (eg. a proxy in front of it);
* any other explicit server port gets http.
* externalServerUrl is used for testing
*/
export function hostToBaseUrl(host: string, port: number, externalServerUrl: string = serverURL): string {
const page = new URL(externalServerUrl);
const pagePort = page.port === '' ? (page.protocol === 'https:' ? 443 : 80) : Number(page.port);
const scheme = page.protocol === 'https:' && pagePort === port ? 'https' : 'http';
return `${scheme}://${host}:${port}`;
}
+3 -3
View File
@@ -242,12 +242,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
return; return;
} }
// skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId); const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey); const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
// we already have this change, or something newer if (revision === cachedRundown?.revision) {
// messages can arrive after a refetch has already brought in a later revision // we already have the latest change
if (revision !== null && cachedRundown !== undefined && revision <= cachedRundown.revision) {
return; return;
} }
+4 -9
View File
@@ -3,9 +3,7 @@ import {
MILLIS_PER_HOUR, MILLIS_PER_HOUR,
MILLIS_PER_MINUTE, MILLIS_PER_MINUTE,
MILLIS_PER_SECOND, MILLIS_PER_SECOND,
dayInMs,
formatFromMillis, formatFromMillis,
getExpectedEnd,
getExpectedStart, getExpectedStart,
} from 'ontime-utils'; } from 'ontime-utils';
@@ -31,11 +29,6 @@ export function nowInMillis(): number {
return elapsed; return elapsed;
} }
export function normaliseWallClock(time: number): number {
const timeOfDay = time % dayInMs;
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
}
/** /**
* @description Resolves format from url and store * @description Resolves format from url and store
* @return {string|null} A format string like "hh:mm:ss a" or null * @return {string|null} A format string like "hh:mm:ss a" or null
@@ -187,13 +180,15 @@ export function getExpectedTimesFromExtendedEvent(
...state, ...state,
}, },
); );
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay; const plannedEnd = event.timeStart + event.duration + event.delay;
return { return {
expectedStart, expectedStart,
timeToStart: expectedStart - state.clock, timeToStart: expectedStart - state.clock,
expectedEnd, expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd, plannedEnd,
}; };
} }
@@ -38,18 +38,6 @@ export function validateProjectFile(file: File) {
} }
} }
/**
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
*/
export function removeFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.slice(0, lastDot);
}
export function isExcelFile(file: File | null) { export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx'); return file?.name.endsWith('.xlsx');
} }
@@ -194,7 +194,6 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
target: path, target: path,
search: url.searchParams.toString(), search: url.searchParams.toString(),
enabled: true, enabled: true,
displayInNav: false,
}; };
} }
+35
View File
@@ -1,3 +1,5 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' { declare module '*.scss' {
const content: Record<string, string>; const content: Record<string, string>;
export default content; 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 * 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 { .contentWrapper {
display: flex; display: flex;
@@ -9,29 +14,9 @@ $content-max-width: 1280px;
position: relative; 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 { .content {
box-sizing: border-box; margin: 1rem;
// 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;
overflow-y: auto; overflow-y: auto;
flex-grow: 1; flex-grow: 1;
// room for the last section to scroll to the top of the viewport padding-bottom: 300px;
padding-bottom: 40vh;
} }
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) { export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return ( return (
<div className={style.contentWrapper}> <div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}> <div className={style.corner}>
<Button size='large' onClick={onClose}> <Button size='large' onClick={onClose}>
Close settings <IoClose /> Close settings <IoClose />
</Button> </Button>
</div> </div>
<div className={style.content}>{children}</div>
</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, .tabs,
ul { ul {
list-style: none; list-style: none;
@@ -16,20 +6,22 @@ ul {
} }
.tabs { .tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto;
} }
.primary, .primary,
.secondary { .secondary {
padding: 0.25rem 1rem; padding: 0.25rem 1rem;
margin-right: 1rem; margin-right: 1rem;
border-radius: 2px;
&:focus-visible { &:focus {
background-color: $gray-1000; background-color: $gray-1000;
outline: 2px solid $blue-500; outline: 0;
outline-offset: -2px;
} }
&:hover { &:hover {
@@ -40,21 +32,14 @@ ul {
.primary { .primary {
font-size: 1rem; font-size: 1rem;
border-radius: 2px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border-left: 3px solid transparent;
&.groupActive {
color: $blue-400;
font-weight: 600;
}
&.active { &.active {
color: $ui-white; color: $blue-400;
border-left-color: $blue-400; background-color: $gray-1100;
background-color: $bg-active;
font-weight: 600;
} }
&.highlight { &.highlight {
@@ -77,9 +62,6 @@ ul {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
&.active { &.active {
color: $ui-white; color: $blue-400;
border-left-color: $blue-400;
background-color: $bg-active;
font-weight: 600;
} }
} }
@@ -1,18 +1,10 @@
import { Fragment, useState } from 'react'; import { Fragment } from 'react';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent'; import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import * as Panel from '../panel-utils/PanelUtils'; import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import {
filterSettingsOptions,
matchesSettingsOptionQuery,
SettingsOption,
SettingsOptionId,
useAppSettingsMenu,
} from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation'; import useAppSettingsNavigation from '../useAppSettingsNavigation';
import SettingsSearch from './SettingsSearch';
import style from './PanelList.module.scss'; import style from './PanelList.module.scss';
@@ -24,57 +16,23 @@ interface PanelListProps extends PanelBaseProps {
selectedPanel: string; 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) { export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu(); 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 ( return (
<div className={style.container}> <ul className={style.tabs}>
<SettingsSearch query={query} onQueryChange={setQuery} onSubmit={handleSearchSubmit} /> {options.map((panel) => {
const isSelected = selectedPanel === panel.id;
{results.length === 0 ? ( if (panel.highlight) {
<Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} /> return (
) : ( <Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<ul className={style.tabs}> <PanelListItem panel={panel} location={location} isSelected={isSelected} />
{results.map((panel) => { </Tooltip>
const isSelected = selectedPanel === panel.id; );
if (panel.highlight) { }
return ( return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
<Tooltip key={panel.id} text={panel.highlight} render={<span />}> })}
<PanelListItem panel={panel} location={location} isSelected={isSelected} /> </ul>
</Tooltip>
);
}
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) { function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
const { setLocation } = useAppSettingsNavigation(); const { setLocation } = useAppSettingsNavigation();
const hasSelectedChild = Boolean( const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
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,
]);
return ( return (
<Fragment key={panel.id}> <Fragment key={panel.id}>
@@ -125,7 +75,6 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
} }
}} }}
className={secondaryClasses} className={secondaryClasses}
tabIndex={0}
role='button' role='button'
> >
{secondary.label} {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; $inner-padding: 1rem;
$card-padding: 2rem;
.header { .header {
font-size: 1.5rem; font-size: 2rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10; border-bottom: 1px solid $white-10;
font-weight: 600; font-weight: 600;
} }
.subheader { .subheader {
font-size: 1.125rem; font-size: 1.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
.title { .title {
font-size: 1rem; font-size: 1.375rem;
padding: 0 var(--panel-card-padding, #{$card-padding}); padding: 0 2rem;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -37,12 +35,10 @@ $card-padding: 2rem;
color: $ui-white; 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 { .section {
position: relative; position: relative;
margin-top: 2rem; margin-top: 2rem;
max-width: 850px;
} }
.indent { .indent {
@@ -55,10 +51,8 @@ $card-padding: 2rem;
} }
.card { .card {
--panel-card-padding: #{$card-padding};
position: relative; position: relative;
padding: var(--panel-card-padding); padding: 2rem;
background-color: $white-3; background-color: $white-3;
border: 1px solid $gray-1100; border: 1px solid $gray-1100;
border-radius: 3px; border-radius: 3px;
@@ -82,10 +76,10 @@ $card-padding: 2rem;
color: $error-red; 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 { .pad {
padding: 0 var(--panel-card-padding, #{$card-padding}); padding: 0 2rem;
max-height: 550px;
overflow-y: scroll;
} }
.table { .table {
@@ -103,13 +97,10 @@ $card-padding: 2rem;
} }
th { th {
font-weight: 600; font-weight: 400;
color: $gray-300; color: $gray-400;
font-size: calc(1rem - 3px);
letter-spacing: 0.02em;
vertical-align: bottom;
white-space: nowrap; white-space: nowrap;
text-transform: uppercase; text-transform: capitalize;
} }
th, th,
@@ -121,17 +112,6 @@ $card-padding: 2rem;
background-color: $white-1; 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 // allow highlighting table elements
tr[data-warn='true'], tr[data-warn='true'],
td[data-warn='true'] { td[data-warn='true'] {
@@ -140,7 +120,7 @@ $card-padding: 2rem;
} }
.listGroup { .listGroup {
padding: 0 var(--panel-card-padding, #{$card-padding}); padding: 0 2rem;
> li:not(:last-child) { > li:not(:last-child) {
border-bottom: 1px solid $white-10; border-bottom: 1px solid $white-10;
@@ -153,17 +133,6 @@ $card-padding: 2rem;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.5rem 0; 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 { .fieldTitle {
@@ -194,13 +163,14 @@ $card-padding: 2rem;
.divider { .divider {
border: none; border: none;
border-top: 1px solid $white-10; border-top: 1px solid $white-10;
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1); margin: 1rem -2rem;
} }
.overlay { .overlay {
position: absolute; position: absolute;
z-index: $zindex-backdrop; z-index: $zindex-backdrop;
inset: 0; width: 100%;
height: 100%;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
display: grid; display: grid;
place-content: center; place-content: center;
@@ -218,35 +188,19 @@ $card-padding: 2rem;
animation: animloader 1s ease-in infinite; animation: animloader 1s ease-in infinite;
} }
.emptyState { .empty {
padding: 3rem 1.5rem; background-color: $black-10;
text-align: center; text-align: center;
} color: $muted-gray;
.emptyMessage { td {
width: min(30rem, 100%); padding-block: 5rem;
margin-inline: auto; }
}
.emptyTitle { button {
margin-bottom: 0.25rem; margin-top: 1rem;
color: rgba($gray-200, 0.72); margin-inline: auto;
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;
margin-top: 1rem;
} }
.inlineElements { .inlineElements {
@@ -285,3 +239,14 @@ $card-padding: 2rem;
opacity: 0; 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 { 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 { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss'; import style from './PanelUtils.module.scss';
@@ -60,29 +62,16 @@ export function Table({ className, children }: { className?: string; children: R
); );
} }
export interface EmptyStateProps { export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
title: string;
description?: ReactNode;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return ( return (
<div className={style.emptyState}> <tr className={style.empty}>
<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>
<td colSpan={99}> <td colSpan={99}>
<EmptyState {...props} /> <div>{label ?? 'No data yet'}</div>
{handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
</td> </td>
</tr> </tr>
); );
@@ -92,8 +81,8 @@ export function ListGroup({ className, children }: { className?: string; childre
return <ul className={cx([style.listGroup, className])}>{children}</ul>; return <ul className={cx([style.listGroup, className])}>{children}</ul>;
} }
export function ListItem({ children, interactive = false }: { children: ReactNode; interactive?: boolean }) { export function ListItem({ children }: { children: ReactNode }) {
return <li className={cx([style.listItem, interactive && style.interactive])}>{children}</li>; return <li className={style.listItem}>{children}</li>;
} }
export function Field({ export function Field({
@@ -1,4 +1,4 @@
import Info from '../../../../common/components/info/Info'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { import {
buyMeACoffeeUrl, buyMeACoffeeUrl,
discordUrl, discordUrl,
@@ -11,8 +11,6 @@ import {
} from '../../../../externals'; } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion'; import AppVersion from './AppVersion';
import CloudPanel from './CloudPanel';
import ExternalLinkRow from './ExternalLinkRow';
export default function AboutPanel() { export default function AboutPanel() {
return ( return (
@@ -21,49 +19,28 @@ export default function AboutPanel() {
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader> <Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Divider /> <Panel.Paragraph>
<Info>Free, open-source software for managing rundowns and event timers.</Info> Free, open-source software for managing rundowns and event timers
<Panel.ListGroup> <ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
<ExternalLinkRow href={websiteUrl} title='www.getontime.no' description='App site and downloads' /> </Panel.Paragraph>
<ExternalLinkRow <Panel.Paragraph>
href={githubSponsorUrl} Considering sponsoring our work
title='GitHub Sponsors' <ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
description='Support development through GitHub' <ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
/> </Panel.Paragraph>
<ExternalLinkRow <Panel.Paragraph>
href={buyMeACoffeeUrl} If you are looking for an online version of Ontime, consider trying out our cloud service
title='Buy Me a Coffee' <ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
description='Make a one-time contribution' </Panel.Paragraph>
/>
</Panel.ListGroup>
</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.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
<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> </Panel.Section>
</> </>
); );
@@ -1,7 +0,0 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -1,47 +1,35 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion'; import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals'; import { appVersion, isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() { export default function AppVersion() {
const { data, isError } = useAppVersion(); const { data, isError } = useAppVersion();
if (isError) { if (isError) {
return ( return (
<Panel.ListItem> <Panel.Paragraph>
<Panel.Field title={`Ontime ${appVersion}`} description='' error='Could not fetch version information' /> {`You are currently using Ontime version ${appVersion}`}
</Panel.ListItem> <Panel.Error>Could not fetch version information</Panel.Error>
</Panel.Paragraph>
); );
} }
if (data.hasUpdates) { if (data.hasUpdates) {
return ( return (
<Panel.ListItem> <Panel.Paragraph>
<Panel.Field {`You are currently using Ontime version ${appVersion}.`}
title={ <br />
<> <br />
<span className={style.updateIndicator} aria-hidden='true' /> {`A new version ${data.version} is available.`} <br />
{`Ontime ${appVersion}`} {isOntimeCloud ? (
</> 'You can restart your stage to get the latest available version.'
} ) : (
description={ <ExternalLink href={data.url}>Please visit the release page to download</ExternalLink>
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
: `Version ${data.version} is available.`
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
)} )}
</Panel.ListItem> </Panel.Paragraph>
); );
} }
return ( return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
<Panel.ListItem>
<Panel.Field title={`Ontime ${appVersion}`} description='You are using the latest version.' />
</Panel.ListItem>
);
} }
@@ -1,74 +0,0 @@
.card {
padding: 1.5rem;
border-color: rgba($action-blue, 0.42);
background-color: rgba($action-blue, 0.06);
}
.eyebrow {
color: $action-text-color;
font-size: calc(1rem - 3px);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.title {
margin-top: 0.35rem;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.15;
}
.description {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 33rem;
margin-top: 0.9rem;
color: $gray-300;
line-height: 1.5;
}
.portability {
padding-top: 0.75rem;
border-top: 1px solid $white-10;
color: $gray-400;
font-size: calc(1rem - 2px);
}
.benefits {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem 1rem;
margin-top: 1.5rem;
padding: 0;
list-style: none;
li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
line-height: 1.4;
}
svg {
flex: 0 0 auto;
margin-top: 0.1rem;
color: $action-text-color;
font-size: 1.1rem;
}
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1.5rem;
}
@media (max-width: $min-tablet) {
.benefits {
grid-template-columns: 1fr;
}
}
@@ -1,52 +0,0 @@
import { IoCheckmarkCircle } from 'react-icons/io5';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './CloudPanel.module.scss';
export default function CloudPanel() {
if (isOntimeCloud) {
return null;
}
return (
<Panel.Section>
<Panel.Card className={style.card}>
<span className={style.eyebrow}>Ontime Cloud</span>
<h3 className={style.title}>Run Ontime online with zero setup</h3>
<p className={style.description}>
<span>
Ontime Cloud lets you run and share Ontime online with your team. Subscriptions support continued
development while Ontime remains fully open-source and self-hostable.
</span>
<span className={style.portability}>
Project files work locally and in Cloud, so your shows can move freely.
</span>
</p>
<ul className={style.benefits}>
<li>
<IoCheckmarkCircle />
Start in minutes, with no setup or maintenance
</li>
<li>
<IoCheckmarkCircle />
Coordinate multi-track rundowns with Overview views
</li>
<li>
<IoCheckmarkCircle />
Manage every instance from one dashboard
</li>
<li>
<IoCheckmarkCircle />
Share secure read-only views with collaborators
</li>
</ul>
<div className={style.actions}>
<ExternalLink href={websiteUrl}>Learn more about Ontime Cloud</ExternalLink>
</div>
</Panel.Card>
</Panel.Section>
);
}

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